summaryrefslogtreecommitdiffstats
path: root/devtools/server
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /devtools/server
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server')
-rw-r--r--devtools/server/actors/accessibility/accessibility.js130
-rw-r--r--devtools/server/actors/accessibility/accessible.js675
-rw-r--r--devtools/server/actors/accessibility/audit/contrast.js306
-rw-r--r--devtools/server/actors/accessibility/audit/keyboard.js514
-rw-r--r--devtools/server/actors/accessibility/audit/moz.build12
-rw-r--r--devtools/server/actors/accessibility/audit/text-label.js438
-rw-r--r--devtools/server/actors/accessibility/constants.js59
-rw-r--r--devtools/server/actors/accessibility/moz.build20
-rw-r--r--devtools/server/actors/accessibility/parent-accessibility.js154
-rw-r--r--devtools/server/actors/accessibility/simulator.js81
-rw-r--r--devtools/server/actors/accessibility/walker.js1315
-rw-r--r--devtools/server/actors/accessibility/worker.js103
-rw-r--r--devtools/server/actors/addon/addons.js83
-rw-r--r--devtools/server/actors/addon/moz.build10
-rw-r--r--devtools/server/actors/addon/webextension-inspected-window.js680
-rw-r--r--devtools/server/actors/animation-type-longhand.js442
-rw-r--r--devtools/server/actors/animation.js906
-rw-r--r--devtools/server/actors/array-buffer.js69
-rw-r--r--devtools/server/actors/blackboxing.js93
-rw-r--r--devtools/server/actors/breakpoint-list.js92
-rw-r--r--devtools/server/actors/breakpoint.js232
-rw-r--r--devtools/server/actors/changes.js125
-rw-r--r--devtools/server/actors/common.js110
-rw-r--r--devtools/server/actors/compatibility/compatibility.js162
-rw-r--r--devtools/server/actors/compatibility/lib/MDNCompatibility.js327
-rw-r--r--devtools/server/actors/compatibility/lib/moz.build11
-rw-r--r--devtools/server/actors/compatibility/lib/test/xpcshell/.eslintrc.js6
-rw-r--r--devtools/server/actors/compatibility/lib/test/xpcshell/head.js10
-rw-r--r--devtools/server/actors/compatibility/lib/test/xpcshell/test_mdn-compatibility.js193
-rw-r--r--devtools/server/actors/compatibility/lib/test/xpcshell/xpcshell.toml7
-rw-r--r--devtools/server/actors/compatibility/moz.build16
-rw-r--r--devtools/server/actors/css-properties.js105
-rw-r--r--devtools/server/actors/descriptors/moz.build12
-rw-r--r--devtools/server/actors/descriptors/process.js246
-rw-r--r--devtools/server/actors/descriptors/tab.js253
-rw-r--r--devtools/server/actors/descriptors/webextension.js336
-rw-r--r--devtools/server/actors/descriptors/worker.js182
-rw-r--r--devtools/server/actors/device.js74
-rw-r--r--devtools/server/actors/emulation/moz.build10
-rw-r--r--devtools/server/actors/emulation/responsive.js83
-rw-r--r--devtools/server/actors/emulation/touch-simulator.js309
-rw-r--r--devtools/server/actors/environment.js206
-rw-r--r--devtools/server/actors/errordocs.js222
-rw-r--r--devtools/server/actors/frame.js225
-rw-r--r--devtools/server/actors/heap-snapshot-file.js72
-rw-r--r--devtools/server/actors/highlighters.js379
-rw-r--r--devtools/server/actors/highlighters/accessible.js395
-rw-r--r--devtools/server/actors/highlighters/auto-refresh.js368
-rw-r--r--devtools/server/actors/highlighters/box-model.js892
-rw-r--r--devtools/server/actors/highlighters/css-grid.js1962
-rw-r--r--devtools/server/actors/highlighters/css-transform.js265
-rw-r--r--devtools/server/actors/highlighters/css/highlighters.css1059
-rw-r--r--devtools/server/actors/highlighters/css/moz.build9
-rw-r--r--devtools/server/actors/highlighters/eye-dropper.js608
-rw-r--r--devtools/server/actors/highlighters/flexbox.js1033
-rw-r--r--devtools/server/actors/highlighters/fonts.js121
-rw-r--r--devtools/server/actors/highlighters/geometry-editor.js808
-rw-r--r--devtools/server/actors/highlighters/measuring-tool.js853
-rw-r--r--devtools/server/actors/highlighters/moz.build31
-rw-r--r--devtools/server/actors/highlighters/node-tabbing-order.js399
-rw-r--r--devtools/server/actors/highlighters/paused-debugger.js260
-rw-r--r--devtools/server/actors/highlighters/remote-node-picker-notice.js188
-rw-r--r--devtools/server/actors/highlighters/rulers.js312
-rw-r--r--devtools/server/actors/highlighters/selector.js97
-rw-r--r--devtools/server/actors/highlighters/shapes.js3263
-rw-r--r--devtools/server/actors/highlighters/tabbing-order.js247
-rw-r--r--devtools/server/actors/highlighters/utils/accessibility.js774
-rw-r--r--devtools/server/actors/highlighters/utils/canvas.js596
-rw-r--r--devtools/server/actors/highlighters/utils/markup.js787
-rw-r--r--devtools/server/actors/highlighters/utils/moz.build7
-rw-r--r--devtools/server/actors/highlighters/viewport-size.js129
-rw-r--r--devtools/server/actors/inspector/constants.js17
-rw-r--r--devtools/server/actors/inspector/css-logic.js1604
-rw-r--r--devtools/server/actors/inspector/custom-element-watcher.js144
-rw-r--r--devtools/server/actors/inspector/document-walker.js196
-rw-r--r--devtools/server/actors/inspector/event-collector.js1069
-rw-r--r--devtools/server/actors/inspector/inspector.js355
-rw-r--r--devtools/server/actors/inspector/moz.build21
-rw-r--r--devtools/server/actors/inspector/node-picker.js435
-rw-r--r--devtools/server/actors/inspector/node.js861
-rw-r--r--devtools/server/actors/inspector/utils.js570
-rw-r--r--devtools/server/actors/inspector/walker.js2764
-rw-r--r--devtools/server/actors/layout.js518
-rw-r--r--devtools/server/actors/manifest.js40
-rw-r--r--devtools/server/actors/memory.js90
-rw-r--r--devtools/server/actors/moz.build90
-rw-r--r--devtools/server/actors/network-monitor/channel-event-sink.js99
-rw-r--r--devtools/server/actors/network-monitor/moz.build12
-rw-r--r--devtools/server/actors/network-monitor/network-content.js140
-rw-r--r--devtools/server/actors/network-monitor/network-event-actor.js684
-rw-r--r--devtools/server/actors/network-monitor/network-parent.js175
-rw-r--r--devtools/server/actors/object.js847
-rw-r--r--devtools/server/actors/object/moz.build12
-rw-r--r--devtools/server/actors/object/previewers.js1142
-rw-r--r--devtools/server/actors/object/private-properties-iterator.js70
-rw-r--r--devtools/server/actors/object/property-iterator.js685
-rw-r--r--devtools/server/actors/object/symbol-iterator.js67
-rw-r--r--devtools/server/actors/object/symbol.js109
-rw-r--r--devtools/server/actors/object/utils.js615
-rw-r--r--devtools/server/actors/objects-manager.js39
-rw-r--r--devtools/server/actors/page-style.js1297
-rw-r--r--devtools/server/actors/pause-scoped.js80
-rw-r--r--devtools/server/actors/perf.js187
-rw-r--r--devtools/server/actors/preference.js108
-rw-r--r--devtools/server/actors/process.js76
-rw-r--r--devtools/server/actors/reflow.js516
-rw-r--r--devtools/server/actors/resources/console-messages.js302
-rw-r--r--devtools/server/actors/resources/css-changes.js42
-rw-r--r--devtools/server/actors/resources/css-messages.js202
-rw-r--r--devtools/server/actors/resources/css-registered-properties.js270
-rw-r--r--devtools/server/actors/resources/document-event.js112
-rw-r--r--devtools/server/actors/resources/error-messages.js192
-rw-r--r--devtools/server/actors/resources/extensions-backgroundscript-status.js68
-rw-r--r--devtools/server/actors/resources/index.js471
-rw-r--r--devtools/server/actors/resources/jstracer-state.js96
-rw-r--r--devtools/server/actors/resources/jstracer-trace.js43
-rw-r--r--devtools/server/actors/resources/last-private-context-exit.js46
-rw-r--r--devtools/server/actors/resources/moz.build44
-rw-r--r--devtools/server/actors/resources/network-events-content.js267
-rw-r--r--devtools/server/actors/resources/network-events-stacktraces.js214
-rw-r--r--devtools/server/actors/resources/network-events.js420
-rw-r--r--devtools/server/actors/resources/parent-process-document-event.js174
-rw-r--r--devtools/server/actors/resources/platform-messages.js60
-rw-r--r--devtools/server/actors/resources/reflow.js63
-rw-r--r--devtools/server/actors/resources/server-sent-events.js135
-rw-r--r--devtools/server/actors/resources/sources.js100
-rw-r--r--devtools/server/actors/resources/storage-cache.js22
-rw-r--r--devtools/server/actors/resources/storage-cookie.js22
-rw-r--r--devtools/server/actors/resources/storage-extension.js30
-rw-r--r--devtools/server/actors/resources/storage-indexed-db.js22
-rw-r--r--devtools/server/actors/resources/storage-local-storage.js22
-rw-r--r--devtools/server/actors/resources/storage-session-storage.js22
-rw-r--r--devtools/server/actors/resources/storage/cache.js195
-rw-r--r--devtools/server/actors/resources/storage/cookies.js559
-rw-r--r--devtools/server/actors/resources/storage/extension-storage.js491
-rw-r--r--devtools/server/actors/resources/storage/index.js404
-rw-r--r--devtools/server/actors/resources/storage/indexed-db.js984
-rw-r--r--devtools/server/actors/resources/storage/local-and-session-storage.js200
-rw-r--r--devtools/server/actors/resources/storage/moz.build17
-rw-r--r--devtools/server/actors/resources/stylesheets.js145
-rw-r--r--devtools/server/actors/resources/thread-states.js136
-rw-r--r--devtools/server/actors/resources/utils/content-process-storage.js453
-rw-r--r--devtools/server/actors/resources/utils/moz.build14
-rw-r--r--devtools/server/actors/resources/utils/nsi-console-listener-watcher.js192
-rw-r--r--devtools/server/actors/resources/utils/parent-process-storage.js580
-rw-r--r--devtools/server/actors/resources/websockets.js196
-rw-r--r--devtools/server/actors/root.js606
-rw-r--r--devtools/server/actors/screenshot-content.js144
-rw-r--r--devtools/server/actors/screenshot.js25
-rw-r--r--devtools/server/actors/source.js694
-rw-r--r--devtools/server/actors/string.js45
-rw-r--r--devtools/server/actors/style-rule.js1328
-rw-r--r--devtools/server/actors/style-sheets.js105
-rw-r--r--devtools/server/actors/target-configuration.js493
-rw-r--r--devtools/server/actors/targets/base-target-actor.js214
-rw-r--r--devtools/server/actors/targets/content-process.js265
-rw-r--r--devtools/server/actors/targets/index.js14
-rw-r--r--devtools/server/actors/targets/moz.build20
-rw-r--r--devtools/server/actors/targets/parent-process.js167
-rw-r--r--devtools/server/actors/targets/session-data-processors/blackboxing.js28
-rw-r--r--devtools/server/actors/targets/session-data-processors/breakpoints.js45
-rw-r--r--devtools/server/actors/targets/session-data-processors/event-breakpoints.js36
-rw-r--r--devtools/server/actors/targets/session-data-processors/index.js50
-rw-r--r--devtools/server/actors/targets/session-data-processors/moz.build16
-rw-r--r--devtools/server/actors/targets/session-data-processors/resources.js25
-rw-r--r--devtools/server/actors/targets/session-data-processors/target-configuration.js32
-rw-r--r--devtools/server/actors/targets/session-data-processors/thread-configuration.js41
-rw-r--r--devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js44
-rw-r--r--devtools/server/actors/targets/target-actor-registry.sys.mjs82
-rw-r--r--devtools/server/actors/targets/webextension.js374
-rw-r--r--devtools/server/actors/targets/window-global.js1935
-rw-r--r--devtools/server/actors/targets/worker.js149
-rw-r--r--devtools/server/actors/thread-configuration.js80
-rw-r--r--devtools/server/actors/thread.js2385
-rw-r--r--devtools/server/actors/tracer.js502
-rw-r--r--devtools/server/actors/utils/accessibility.js103
-rw-r--r--devtools/server/actors/utils/actor-registry.js418
-rw-r--r--devtools/server/actors/utils/breakpoint-actor-map.js84
-rw-r--r--devtools/server/actors/utils/capture-screenshot.js200
-rw-r--r--devtools/server/actors/utils/css-grid-utils.js60
-rw-r--r--devtools/server/actors/utils/custom-formatters.js499
-rw-r--r--devtools/server/actors/utils/dbg-source.js97
-rw-r--r--devtools/server/actors/utils/event-breakpoints.js508
-rw-r--r--devtools/server/actors/utils/event-loop.js221
-rw-r--r--devtools/server/actors/utils/gecko-profile-collector.js285
-rw-r--r--devtools/server/actors/utils/inactive-property-helper.js1443
-rw-r--r--devtools/server/actors/utils/logEvent.js112
-rw-r--r--devtools/server/actors/utils/make-debugger.js122
-rw-r--r--devtools/server/actors/utils/moz.build32
-rw-r--r--devtools/server/actors/utils/shapes-utils.js149
-rw-r--r--devtools/server/actors/utils/source-map-utils.js42
-rw-r--r--devtools/server/actors/utils/source-url.js44
-rw-r--r--devtools/server/actors/utils/sources-manager.js515
-rw-r--r--devtools/server/actors/utils/stack.js183
-rw-r--r--devtools/server/actors/utils/style-utils.js211
-rw-r--r--devtools/server/actors/utils/stylesheet-utils.js155
-rw-r--r--devtools/server/actors/utils/stylesheets-manager.js1031
-rw-r--r--devtools/server/actors/utils/track-change-emitter.js19
-rw-r--r--devtools/server/actors/utils/walker-search.js320
-rw-r--r--devtools/server/actors/utils/watchpoint-map.js163
-rw-r--r--devtools/server/actors/watcher.js864
-rw-r--r--devtools/server/actors/watcher/SessionDataHelpers.jsm244
-rw-r--r--devtools/server/actors/watcher/WatcherRegistry.sys.mjs397
-rw-r--r--devtools/server/actors/watcher/browsing-context-helpers.sys.mjs428
-rw-r--r--devtools/server/actors/watcher/moz.build16
-rw-r--r--devtools/server/actors/watcher/session-context.js219
-rw-r--r--devtools/server/actors/watcher/target-helpers/frame-helper.js331
-rw-r--r--devtools/server/actors/watcher/target-helpers/moz.build13
-rw-r--r--devtools/server/actors/watcher/target-helpers/process-helper.js389
-rw-r--r--devtools/server/actors/watcher/target-helpers/service-worker-helper.js220
-rw-r--r--devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js26
-rw-r--r--devtools/server/actors/watcher/target-helpers/worker-helper.js137
-rw-r--r--devtools/server/actors/webbrowser.js776
-rw-r--r--devtools/server/actors/webconsole.js1736
-rw-r--r--devtools/server/actors/webconsole/commands/experimental-commands.ftl21
-rw-r--r--devtools/server/actors/webconsole/commands/manager.js901
-rw-r--r--devtools/server/actors/webconsole/commands/moz.build10
-rw-r--r--devtools/server/actors/webconsole/commands/parser.js249
-rw-r--r--devtools/server/actors/webconsole/eager-ecma-allowlist.js249
-rw-r--r--devtools/server/actors/webconsole/eager-function-allowlist.js52
-rw-r--r--devtools/server/actors/webconsole/eval-with-debugger.js710
-rw-r--r--devtools/server/actors/webconsole/listeners/console-api.js255
-rw-r--r--devtools/server/actors/webconsole/listeners/console-file-activity.js126
-rw-r--r--devtools/server/actors/webconsole/listeners/console-reflow.js90
-rw-r--r--devtools/server/actors/webconsole/listeners/console-service.js193
-rw-r--r--devtools/server/actors/webconsole/listeners/document-events.js247
-rw-r--r--devtools/server/actors/webconsole/listeners/moz.build13
-rw-r--r--devtools/server/actors/webconsole/moz.build20
-rw-r--r--devtools/server/actors/webconsole/utils.js160
-rw-r--r--devtools/server/actors/webconsole/webidl-pure-allowlist.js87
-rw-r--r--devtools/server/actors/webconsole/webidl-unsafe-getters-names.js20
-rw-r--r--devtools/server/actors/webconsole/worker-listeners.js35
-rw-r--r--devtools/server/actors/worker/moz.build13
-rw-r--r--devtools/server/actors/worker/push-subscription.js38
-rw-r--r--devtools/server/actors/worker/service-worker-registration-list.js114
-rw-r--r--devtools/server/actors/worker/service-worker-registration.js264
-rw-r--r--devtools/server/actors/worker/service-worker.js44
-rw-r--r--devtools/server/actors/worker/worker-descriptor-actor-list.js213
-rw-r--r--devtools/server/connectors/content-process-connector.js125
-rw-r--r--devtools/server/connectors/frame-connector.js171
-rw-r--r--devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs706
-rw-r--r--devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs279
-rw-r--r--devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs571
-rw-r--r--devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs300
-rw-r--r--devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs76
-rw-r--r--devtools/server/connectors/js-window-actor/moz.build13
-rw-r--r--devtools/server/connectors/moz.build16
-rw-r--r--devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs741
-rw-r--r--devtools/server/connectors/process-actor/DevToolsServiceWorkerParent.sys.mjs314
-rw-r--r--devtools/server/connectors/process-actor/moz.build10
-rw-r--r--devtools/server/connectors/worker-connector.js208
-rw-r--r--devtools/server/devtools-server-connection.js543
-rw-r--r--devtools/server/devtools-server.js513
-rw-r--r--devtools/server/jar.mn8
-rw-r--r--devtools/server/moz.build32
-rw-r--r--devtools/server/performance/memory.js502
-rw-r--r--devtools/server/performance/moz.build12
-rw-r--r--devtools/server/socket/moz.build11
-rw-r--r--devtools/server/socket/tests/chrome/chrome.toml4
-rw-r--r--devtools/server/socket/tests/chrome/test_websocket-server.html88
-rw-r--r--devtools/server/socket/websocket-server.js250
-rw-r--r--devtools/server/startup/content-process-script.js282
-rw-r--r--devtools/server/startup/content-process.js33
-rw-r--r--devtools/server/startup/content-process.sys.mjs104
-rw-r--r--devtools/server/startup/frame.js193
-rw-r--r--devtools/server/startup/moz.build13
-rw-r--r--devtools/server/startup/worker.js159
-rw-r--r--devtools/server/tests/browser/animation-data.html115
-rw-r--r--devtools/server/tests/browser/animation.html170
-rw-r--r--devtools/server/tests/browser/application-manifest-404-manifest.html10
-rw-r--r--devtools/server/tests/browser/application-manifest-basic.html10
-rw-r--r--devtools/server/tests/browser/application-manifest-invalid-json.html11
-rw-r--r--devtools/server/tests/browser/application-manifest-no-manifest.html9
-rw-r--r--devtools/server/tests/browser/application-manifest-warnings.html10
-rw-r--r--devtools/server/tests/browser/browser.toml213
-rw-r--r--devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js73
-rw-r--r--devtools/server/tests/browser/browser_accessibility_infobar_audit_keyboard.js157
-rw-r--r--devtools/server/tests/browser/browser_accessibility_infobar_audit_text_label.js164
-rw-r--r--devtools/server/tests/browser/browser_accessibility_infobar_show.js181
-rw-r--r--devtools/server/tests/browser/browser_accessibility_keyboard_audit.js367
-rw-r--r--devtools/server/tests/browser/browser_accessibility_node.js166
-rw-r--r--devtools/server/tests/browser/browser_accessibility_node_audit.js116
-rw-r--r--devtools/server/tests/browser/browser_accessibility_node_events.js197
-rw-r--r--devtools/server/tests/browser/browser_accessibility_node_tabbing_order_highlighter.js92
-rw-r--r--devtools/server/tests/browser/browser_accessibility_simple.js106
-rw-r--r--devtools/server/tests/browser/browser_accessibility_simulator.js88
-rw-r--r--devtools/server/tests/browser/browser_accessibility_tabbing_order_highlighter.js101
-rw-r--r--devtools/server/tests/browser/browser_accessibility_text_label_audit.js1134
-rw-r--r--devtools/server/tests/browser/browser_accessibility_text_label_audit_frame.js48
-rw-r--r--devtools/server/tests/browser/browser_accessibility_walker.js170
-rw-r--r--devtools/server/tests/browser/browser_accessibility_walker_audit.js155
-rw-r--r--devtools/server/tests/browser/browser_actor_error.js94
-rw-r--r--devtools/server/tests/browser/browser_animation_actor-lifetime.js80
-rw-r--r--devtools/server/tests/browser/browser_animation_emitMutations.js72
-rw-r--r--devtools/server/tests/browser/browser_animation_getMultipleStates.js63
-rw-r--r--devtools/server/tests/browser/browser_animation_getPlayers.js39
-rw-r--r--devtools/server/tests/browser/browser_animation_getStateAfterFinished.js76
-rw-r--r--devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js50
-rw-r--r--devtools/server/tests/browser/browser_animation_keepFinished.js55
-rw-r--r--devtools/server/tests/browser/browser_animation_playPauseIframe.js70
-rw-r--r--devtools/server/tests/browser/browser_animation_playPauseSeveral.js67
-rw-r--r--devtools/server/tests/browser/browser_animation_playerState.js159
-rw-r--r--devtools/server/tests/browser/browser_animation_reconstructState.js40
-rw-r--r--devtools/server/tests/browser/browser_animation_refreshTransitions.js97
-rw-r--r--devtools/server/tests/browser/browser_animation_setCurrentTime.js47
-rw-r--r--devtools/server/tests/browser/browser_animation_setPlaybackRate.js49
-rw-r--r--devtools/server/tests/browser/browser_animation_simple.js39
-rw-r--r--devtools/server/tests/browser/browser_animation_updatedState.js66
-rw-r--r--devtools/server/tests/browser/browser_application_manifest.js87
-rw-r--r--devtools/server/tests/browser/browser_canvasframe_helper_01.js170
-rw-r--r--devtools/server/tests/browser/browser_canvasframe_helper_02.js53
-rw-r--r--devtools/server/tests/browser/browser_canvasframe_helper_03.js129
-rw-r--r--devtools/server/tests/browser/browser_canvasframe_helper_04.js142
-rw-r--r--devtools/server/tests/browser/browser_canvasframe_helper_05.js134
-rw-r--r--devtools/server/tests/browser/browser_canvasframe_helper_06.js116
-rw-r--r--devtools/server/tests/browser/browser_compatibility_cssIssues.js137
-rw-r--r--devtools/server/tests/browser/browser_connectToFrame.js142
-rw-r--r--devtools/server/tests/browser/browser_debugger_server.js198
-rw-r--r--devtools/server/tests/browser/browser_document_devtools_basics.js103
-rw-r--r--devtools/server/tests/browser/browser_document_rdp_basics.js129
-rw-r--r--devtools/server/tests/browser/browser_getProcess.js129
-rw-r--r--devtools/server/tests/browser/browser_inspector-anonymous.js204
-rw-r--r--devtools/server/tests/browser/browser_inspector-iframe.js93
-rw-r--r--devtools/server/tests/browser/browser_inspector-insert.js158
-rw-r--r--devtools/server/tests/browser/browser_inspector-isScrollable.js34
-rw-r--r--devtools/server/tests/browser/browser_inspector-mutations-childlist.js282
-rw-r--r--devtools/server/tests/browser/browser_inspector-release.js54
-rw-r--r--devtools/server/tests/browser/browser_inspector-remove.js102
-rw-r--r--devtools/server/tests/browser/browser_inspector-retain.js157
-rw-r--r--devtools/server/tests/browser/browser_inspector-search.js347
-rw-r--r--devtools/server/tests/browser/browser_inspector-shadow.js231
-rw-r--r--devtools/server/tests/browser/browser_inspector-traversal.js350
-rw-r--r--devtools/server/tests/browser/browser_inspector-utils.js25
-rw-r--r--devtools/server/tests/browser/browser_layout_getGrids.js145
-rw-r--r--devtools/server/tests/browser/browser_layout_simple.js31
-rw-r--r--devtools/server/tests/browser/browser_memory_allocations_01.js107
-rw-r--r--devtools/server/tests/browser/browser_perf-01.js57
-rw-r--r--devtools/server/tests/browser/browser_perf-02.js37
-rw-r--r--devtools/server/tests/browser/browser_perf-04.js53
-rw-r--r--devtools/server/tests/browser/browser_perf-getSupportedFeatures.js23
-rw-r--r--devtools/server/tests/browser/browser_storage_cookies-duplicate-names.js134
-rw-r--r--devtools/server/tests/browser/browser_storage_dynamic_windows.js410
-rw-r--r--devtools/server/tests/browser/browser_storage_listings.js743
-rw-r--r--devtools/server/tests/browser/browser_storage_updates.js343
-rw-r--r--devtools/server/tests/browser/browser_style_utils_getFontPreviewData.js137
-rw-r--r--devtools/server/tests/browser/browser_styles_getRuleText.js34
-rw-r--r--devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js53
-rw-r--r--devtools/server/tests/browser/director-script-target.html18
-rw-r--r--devtools/server/tests/browser/doc_accessibility.html19
-rw-r--r--devtools/server/tests/browser/doc_accessibility_audit.html10
-rw-r--r--devtools/server/tests/browser/doc_accessibility_infobar.html12
-rw-r--r--devtools/server/tests/browser/doc_accessibility_keyboard_audit.html150
-rw-r--r--devtools/server/tests/browser/doc_accessibility_text_label_audit.html463
-rw-r--r--devtools/server/tests/browser/doc_accessibility_text_label_audit_frame.html10
-rw-r--r--devtools/server/tests/browser/doc_allocations.html23
-rw-r--r--devtools/server/tests/browser/doc_compatibility.html28
-rw-r--r--devtools/server/tests/browser/doc_force_cc.html32
-rw-r--r--devtools/server/tests/browser/doc_force_gc.html31
-rw-r--r--devtools/server/tests/browser/doc_iframe.html17
-rw-r--r--devtools/server/tests/browser/doc_iframe2.html15
-rw-r--r--devtools/server/tests/browser/doc_iframe_content.html14
-rw-r--r--devtools/server/tests/browser/doc_innerHTML.html21
-rw-r--r--devtools/server/tests/browser/error-actor.js25
-rw-r--r--devtools/server/tests/browser/grid.html42
-rw-r--r--devtools/server/tests/browser/head.js337
-rw-r--r--devtools/server/tests/browser/inspector-helpers.js161
-rw-r--r--devtools/server/tests/browser/inspector-isScrollable-data.html79
-rw-r--r--devtools/server/tests/browser/inspector-search-data.html54
-rw-r--r--devtools/server/tests/browser/inspector-shadow.html117
-rw-r--r--devtools/server/tests/browser/inspector-traversal-data.html98
-rw-r--r--devtools/server/tests/browser/storage-cookies-same-name.html29
-rw-r--r--devtools/server/tests/browser/storage-dynamic-windows.html117
-rw-r--r--devtools/server/tests/browser/storage-helpers.js50
-rw-r--r--devtools/server/tests/browser/storage-listings.html123
-rw-r--r--devtools/server/tests/browser/storage-secured-iframe.html94
-rw-r--r--devtools/server/tests/browser/storage-unsecured-iframe.html28
-rw-r--r--devtools/server/tests/browser/storage-updates.html47
-rw-r--r--devtools/server/tests/browser/test-errors-actor.js72
-rw-r--r--devtools/server/tests/browser/test-window.xhtml5
-rw-r--r--devtools/server/tests/chrome/Debugger.Source.prototype.element-2.js4
-rw-r--r--devtools/server/tests/chrome/Debugger.Source.prototype.element.html25
-rw-r--r--devtools/server/tests/chrome/Debugger.Source.prototype.element.js7
-rw-r--r--devtools/server/tests/chrome/chrome.toml150
-rw-r--r--devtools/server/tests/chrome/doc_Debugger.Source.prototype.introductionType.xhtml7
-rw-r--r--devtools/server/tests/chrome/hello-actor.js28
-rw-r--r--devtools/server/tests/chrome/iframe1_makeGlobalObjectReference.html1
-rw-r--r--devtools/server/tests/chrome/iframe2_makeGlobalObjectReference.html1
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/align-content.mjs92
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/border-image.mjs162
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/cue-pseudo-element.mjs371
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/first-letter-pseudo-element.mjs32
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/first-line-pseudo-element.mjs50
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/flex-grid-item-properties.mjs229
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/float.mjs76
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/gap.mjs133
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/grid-container-properties.mjs43
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/grid-with-absolute-properties.mjs71
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/highlight-pseudo-elements.mjs155
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/margin-padding.mjs260
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/max-min-width-height.mjs366
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/multicol-container-properties.mjs39
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/place-items-content.mjs159
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/placeholder-pseudo-element.mjs122
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/positioned.mjs82
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/scroll-padding.mjs159
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/table-cell.mjs21
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/table.mjs28
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/text-overflow.mjs92
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/text-wrap.mjs86
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/vertical-align.mjs56
-rw-r--r--devtools/server/tests/chrome/inactive-property-helper/width-height-ruby.mjs147
-rw-r--r--devtools/server/tests/chrome/inspector-delay-image-response.sjs46
-rw-r--r--devtools/server/tests/chrome/inspector-eyedropper.html20
-rw-r--r--devtools/server/tests/chrome/inspector-helpers.js133
-rw-r--r--devtools/server/tests/chrome/inspector-search-data.html54
-rw-r--r--devtools/server/tests/chrome/inspector-styles-data.css3
-rw-r--r--devtools/server/tests/chrome/inspector-styles-data.html85
-rw-r--r--devtools/server/tests/chrome/inspector-template.html17
-rw-r--r--devtools/server/tests/chrome/inspector-traversal-data.html103
-rw-r--r--devtools/server/tests/chrome/inspector_css-properties.html12
-rw-r--r--devtools/server/tests/chrome/inspector_display-type.html17
-rw-r--r--devtools/server/tests/chrome/inspector_getImageData.html23
-rw-r--r--devtools/server/tests/chrome/inspector_getOffsetParent.html18
-rw-r--r--devtools/server/tests/chrome/large-image.jpgbin0 -> 793541 bytes
-rw-r--r--devtools/server/tests/chrome/memory-helpers.js72
-rw-r--r--devtools/server/tests/chrome/nonchrome_unsafeDereference.html10
-rw-r--r--devtools/server/tests/chrome/small-image.gifbin0 -> 510655 bytes
-rw-r--r--devtools/server/tests/chrome/suspendTimeouts_content.html1
-rw-r--r--devtools/server/tests/chrome/suspendTimeouts_content.js75
-rw-r--r--devtools/server/tests/chrome/suspendTimeouts_worker.js12
-rw-r--r--devtools/server/tests/chrome/test_Debugger.Script.prototype.global.html48
-rw-r--r--devtools/server/tests/chrome/test_Debugger.Source.prototype.elementAttribute.html159
-rw-r--r--devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionScript.html96
-rw-r--r--devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionType.html159
-rw-r--r--devtools/server/tests/chrome/test_animation-type-longhand.html42
-rw-r--r--devtools/server/tests/chrome/test_css-logic-specificity.html84
-rw-r--r--devtools/server/tests/chrome/test_css-logic.html73
-rw-r--r--devtools/server/tests/chrome/test_css-properties.html72
-rw-r--r--devtools/server/tests/chrome/test_device.html79
-rw-r--r--devtools/server/tests/chrome/test_executeInGlobal-outerized_this.html73
-rw-r--r--devtools/server/tests/chrome/test_highlighter_paused_debugger.html88
-rw-r--r--devtools/server/tests/chrome/test_inspector-changeattrs.html90
-rw-r--r--devtools/server/tests/chrome/test_inspector-changevalue.html68
-rw-r--r--devtools/server/tests/chrome/test_inspector-display-type.html81
-rw-r--r--devtools/server/tests/chrome/test_inspector-duplicate-node.html61
-rw-r--r--devtools/server/tests/chrome/test_inspector-hide.html71
-rw-r--r--devtools/server/tests/chrome/test_inspector-inactive-property-helper.html124
-rw-r--r--devtools/server/tests/chrome/test_inspector-mutations-attr.html169
-rw-r--r--devtools/server/tests/chrome/test_inspector-mutations-events.html187
-rw-r--r--devtools/server/tests/chrome/test_inspector-mutations-value.html163
-rw-r--r--devtools/server/tests/chrome/test_inspector-pick-color.html94
-rw-r--r--devtools/server/tests/chrome/test_inspector-pseudoclass-lock.html185
-rw-r--r--devtools/server/tests/chrome/test_inspector-reload.html90
-rw-r--r--devtools/server/tests/chrome/test_inspector-resize.html69
-rw-r--r--devtools/server/tests/chrome/test_inspector-resolve-url.html87
-rw-r--r--devtools/server/tests/chrome/test_inspector-scroll-into-view.html60
-rw-r--r--devtools/server/tests/chrome/test_inspector-search-front.html163
-rw-r--r--devtools/server/tests/chrome/test_inspector-template.html66
-rw-r--r--devtools/server/tests/chrome/test_inspector_getImageData-wait-for-load.html133
-rw-r--r--devtools/server/tests/chrome/test_inspector_getImageData.html142
-rw-r--r--devtools/server/tests/chrome/test_inspector_getImageDataFromURL.html116
-rw-r--r--devtools/server/tests/chrome/test_inspector_getNodeFromActor.html84
-rw-r--r--devtools/server/tests/chrome/test_inspector_getOffsetParent.html129
-rw-r--r--devtools/server/tests/chrome/test_makeGlobalObjectReference.html96
-rw-r--r--devtools/server/tests/chrome/test_memory.html39
-rw-r--r--devtools/server/tests/chrome/test_memory_allocations_02.html80
-rw-r--r--devtools/server/tests/chrome/test_memory_allocations_03.html80
-rw-r--r--devtools/server/tests/chrome/test_memory_allocations_04.html62
-rw-r--r--devtools/server/tests/chrome/test_memory_allocations_05.html93
-rw-r--r--devtools/server/tests/chrome/test_memory_allocations_06.html51
-rw-r--r--devtools/server/tests/chrome/test_memory_allocations_07.html58
-rw-r--r--devtools/server/tests/chrome/test_memory_attach_01.html33
-rw-r--r--devtools/server/tests/chrome/test_memory_attach_02.html44
-rw-r--r--devtools/server/tests/chrome/test_memory_census.html35
-rw-r--r--devtools/server/tests/chrome/test_memory_gc_01.html50
-rw-r--r--devtools/server/tests/chrome/test_memory_gc_events.html44
-rw-r--r--devtools/server/tests/chrome/test_overflowing-body.html42
-rw-r--r--devtools/server/tests/chrome/test_overflowing-children.html131
-rw-r--r--devtools/server/tests/chrome/test_preference.html128
-rw-r--r--devtools/server/tests/chrome/test_styles-applied.html155
-rw-r--r--devtools/server/tests/chrome/test_styles-computed.html130
-rw-r--r--devtools/server/tests/chrome/test_styles-layout.html110
-rw-r--r--devtools/server/tests/chrome/test_styles-matched.html110
-rw-r--r--devtools/server/tests/chrome/test_styles-modify.html110
-rw-r--r--devtools/server/tests/chrome/test_styles-svg.html61
-rw-r--r--devtools/server/tests/chrome/test_suspendTimeouts.html20
-rw-r--r--devtools/server/tests/chrome/test_suspendTimeouts.js139
-rw-r--r--devtools/server/tests/chrome/test_unsafeDereference.html53
-rw-r--r--devtools/server/tests/chrome/test_webconsole-node-grip.html66
-rw-r--r--devtools/server/tests/chrome/webconsole-helpers.js54
-rw-r--r--devtools/server/tests/xpcshell/.eslintrc.js9
-rw-r--r--devtools/server/tests/xpcshell/addons/web-extension-upgrade/manifest.json10
-rw-r--r--devtools/server/tests/xpcshell/addons/web-extension/manifest.json10
-rw-r--r--devtools/server/tests/xpcshell/addons/web-extension2/manifest.json10
-rw-r--r--devtools/server/tests/xpcshell/completions.js23
-rw-r--r--devtools/server/tests/xpcshell/head_dbg.js984
-rw-r--r--devtools/server/tests/xpcshell/hello-actor.js23
-rw-r--r--devtools/server/tests/xpcshell/post_init_global_actors.js22
-rw-r--r--devtools/server/tests/xpcshell/post_init_target_scoped_actors.js22
-rw-r--r--devtools/server/tests/xpcshell/pre_init_global_actors.js22
-rw-r--r--devtools/server/tests/xpcshell/pre_init_target_scoped_actors.js22
-rw-r--r--devtools/server/tests/xpcshell/registertestactors-lazy.js43
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-column-in-gcd-script.js7
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-column-minified.js8
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets-in-gcd-script.js7
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets.js5
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-column.js5
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-line-in-gcd-script.js9
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-offsets.js7
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-statements.js5
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets-in-gcd-script.js9
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets.js7
-rw-r--r--devtools/server/tests/xpcshell/setBreakpoint-on-line.js7
-rw-r--r--devtools/server/tests/xpcshell/source-03.js7
-rw-r--r--devtools/server/tests/xpcshell/source-map-data/sourcemapped.coffee6
-rw-r--r--devtools/server/tests/xpcshell/source-map-data/sourcemapped.map10
-rw-r--r--devtools/server/tests/xpcshell/sourcemapped.js16
-rw-r--r--devtools/server/tests/xpcshell/stepping-async.js31
-rw-r--r--devtools/server/tests/xpcshell/stepping.js36
-rw-r--r--devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_01.js22
-rw-r--r--devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_02.js24
-rw-r--r--devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_03.js22
-rw-r--r--devtools/server/tests/xpcshell/test_add_actors.js107
-rw-r--r--devtools/server/tests/xpcshell/test_addon_debugging_connect.js158
-rw-r--r--devtools/server/tests/xpcshell/test_addon_events.js60
-rw-r--r--devtools/server/tests/xpcshell/test_addon_reload.js116
-rw-r--r--devtools/server/tests/xpcshell/test_addons_actor.js55
-rw-r--r--devtools/server/tests/xpcshell/test_animation_name.js93
-rw-r--r--devtools/server/tests/xpcshell/test_animation_type.js72
-rw-r--r--devtools/server/tests/xpcshell/test_attach.js28
-rw-r--r--devtools/server/tests/xpcshell/test_blackboxing-01.js155
-rw-r--r--devtools/server/tests/xpcshell/test_blackboxing-02.js95
-rw-r--r--devtools/server/tests/xpcshell/test_blackboxing-03.js115
-rw-r--r--devtools/server/tests/xpcshell/test_blackboxing-04.js70
-rw-r--r--devtools/server/tests/xpcshell/test_blackboxing-05.js97
-rw-r--r--devtools/server/tests/xpcshell/test_blackboxing-08.js52
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-01.js53
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-03.js74
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-04.js56
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-05.js62
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-06.js68
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-07.js65
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-08.js75
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-09.js72
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-10.js81
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-11.js77
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-12.js93
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-13.js78
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-14.js90
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-16.js70
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-17.js130
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-18.js60
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-19.js45
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-20.js109
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-21.js62
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-22.js60
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-23.js35
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-24.js239
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-25.js79
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-26.js63
-rw-r--r--devtools/server/tests/xpcshell/test_breakpoint-actor-map.js257
-rw-r--r--devtools/server/tests/xpcshell/test_client_request.js220
-rw-r--r--devtools/server/tests/xpcshell/test_conditional_breakpoint-01.js54
-rw-r--r--devtools/server/tests/xpcshell/test_conditional_breakpoint-02.js52
-rw-r--r--devtools/server/tests/xpcshell/test_conditional_breakpoint-03.js52
-rw-r--r--devtools/server/tests/xpcshell/test_conditional_breakpoint-04.js56
-rw-r--r--devtools/server/tests/xpcshell/test_connection_closes_all_pools.js100
-rw-r--r--devtools/server/tests/xpcshell/test_console_eval-01.js33
-rw-r--r--devtools/server/tests/xpcshell/test_console_eval-02.js22
-rw-r--r--devtools/server/tests/xpcshell/test_dbgactor.js46
-rw-r--r--devtools/server/tests/xpcshell/test_dbgclient_debuggerstatement.js39
-rw-r--r--devtools/server/tests/xpcshell/test_dbgglobal.js86
-rw-r--r--devtools/server/tests/xpcshell/test_extension_storage_actor.js1155
-rw-r--r--devtools/server/tests/xpcshell/test_extension_storage_actor_upgrade.js142
-rw-r--r--devtools/server/tests/xpcshell/test_forwardingprefix.js226
-rw-r--r--devtools/server/tests/xpcshell/test_frameactor-01.js35
-rw-r--r--devtools/server/tests/xpcshell/test_frameactor-02.js36
-rw-r--r--devtools/server/tests/xpcshell/test_frameactor-03.js54
-rw-r--r--devtools/server/tests/xpcshell/test_frameactor-04.js64
-rw-r--r--devtools/server/tests/xpcshell/test_frameactor-05.js39
-rw-r--r--devtools/server/tests/xpcshell/test_frameactor_wasm-01.js67
-rw-r--r--devtools/server/tests/xpcshell/test_framearguments-01.js43
-rw-r--r--devtools/server/tests/xpcshell/test_framebindings-01.js71
-rw-r--r--devtools/server/tests/xpcshell/test_framebindings-02.js60
-rw-r--r--devtools/server/tests/xpcshell/test_framebindings-03.js63
-rw-r--r--devtools/server/tests/xpcshell/test_framebindings-04.js77
-rw-r--r--devtools/server/tests/xpcshell/test_framebindings-05.js54
-rw-r--r--devtools/server/tests/xpcshell/test_framebindings-06.js45
-rw-r--r--devtools/server/tests/xpcshell/test_framebindings-07.js41
-rw-r--r--devtools/server/tests/xpcshell/test_front_destroy.js42
-rw-r--r--devtools/server/tests/xpcshell/test_functiongrips-01.js64
-rw-r--r--devtools/server/tests/xpcshell/test_getRuleText.js143
-rw-r--r--devtools/server/tests/xpcshell/test_getTextAtLineColumn.js35
-rw-r--r--devtools/server/tests/xpcshell/test_get_command_and_arg.js121
-rw-r--r--devtools/server/tests/xpcshell/test_getyoungestframe.js38
-rw-r--r--devtools/server/tests/xpcshell/test_ignore_caught_exceptions.js53
-rw-r--r--devtools/server/tests/xpcshell/test_ignore_no_interface_exceptions.js50
-rw-r--r--devtools/server/tests/xpcshell/test_interrupt.js15
-rw-r--r--devtools/server/tests/xpcshell/test_layout-reflows-observer.js311
-rw-r--r--devtools/server/tests/xpcshell/test_listsources-01.js56
-rw-r--r--devtools/server/tests/xpcshell/test_listsources-02.js36
-rw-r--r--devtools/server/tests/xpcshell/test_listsources-03.js45
-rw-r--r--devtools/server/tests/xpcshell/test_logpoint-01.js83
-rw-r--r--devtools/server/tests/xpcshell/test_logpoint-02.js85
-rw-r--r--devtools/server/tests/xpcshell/test_logpoint-03.js82
-rw-r--r--devtools/server/tests/xpcshell/test_longstringgrips-01.js75
-rw-r--r--devtools/server/tests/xpcshell/test_nativewrappers.js39
-rw-r--r--devtools/server/tests/xpcshell/test_nesting-03.js50
-rw-r--r--devtools/server/tests/xpcshell/test_nesting-04.js86
-rw-r--r--devtools/server/tests/xpcshell/test_new_source-01.js24
-rw-r--r--devtools/server/tests/xpcshell/test_new_source-02.js46
-rw-r--r--devtools/server/tests/xpcshell/test_nodelistactor.js30
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-02.js44
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-03.js52
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-04.js54
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-05.js56
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-06.js56
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-07.js65
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-08.js61
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-14.js55
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-15.js53
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-16.js139
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-17.js320
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-18.js173
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-19.js75
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-20.js387
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-21.js396
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-22.js50
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-23.js45
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-24.js57
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-25.js131
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-fn-apply-01.js117
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-fn-apply-02.js56
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-fn-apply-03.js51
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-nested-promise.js56
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-nested-proxy.js51
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-property-value-01.js148
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-property-value-02.js53
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-property-value-03.js63
-rw-r--r--devtools/server/tests/xpcshell/test_objectgrips-sparse-array.js40
-rw-r--r--devtools/server/tests/xpcshell/test_pause_exceptions-01.js43
-rw-r--r--devtools/server/tests/xpcshell/test_pause_exceptions-02.js40
-rw-r--r--devtools/server/tests/xpcshell/test_pause_exceptions-03.js53
-rw-r--r--devtools/server/tests/xpcshell/test_pause_exceptions-04.js93
-rw-r--r--devtools/server/tests/xpcshell/test_pauselifetime-01.js54
-rw-r--r--devtools/server/tests/xpcshell/test_pauselifetime-02.js57
-rw-r--r--devtools/server/tests/xpcshell/test_pauselifetime-03.js64
-rw-r--r--devtools/server/tests/xpcshell/test_pauselifetime-04.js40
-rw-r--r--devtools/server/tests/xpcshell/test_promise_state-01.js44
-rw-r--r--devtools/server/tests/xpcshell/test_promise_state-02.js59
-rw-r--r--devtools/server/tests/xpcshell/test_promise_state-03.js58
-rw-r--r--devtools/server/tests/xpcshell/test_promises_run_to_completion.js132
-rw-r--r--devtools/server/tests/xpcshell/test_register_actor.js94
-rw-r--r--devtools/server/tests/xpcshell/test_requestTypes.js28
-rw-r--r--devtools/server/tests/xpcshell/test_restartFrame-01.js118
-rw-r--r--devtools/server/tests/xpcshell/test_safe-getter.js54
-rw-r--r--devtools/server/tests/xpcshell/test_sessionDataHelpers.js124
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-at-the-beginning-of-a-minified-fn.js41
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-at-the-end-of-a-minified-fn.js41
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-column-in-gcd-script.js46
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-column.js36
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-line-in-gcd-script.js45
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-offsets.js52
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-statements.js38
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js56
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets.js44
-rw-r--r--devtools/server/tests/xpcshell/test_setBreakpoint-on-line.js36
-rw-r--r--devtools/server/tests/xpcshell/test_shapes_highlighter_helpers.js274
-rw-r--r--devtools/server/tests/xpcshell/test_source-01.js58
-rw-r--r--devtools/server/tests/xpcshell/test_source-02.js64
-rw-r--r--devtools/server/tests/xpcshell/test_source-03.js75
-rw-r--r--devtools/server/tests/xpcshell/test_source-04.js74
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-01.js94
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-02.js57
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-03.js43
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-04.js50
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-05.js0
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-06.js0
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-07.js0
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-08.js0
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-09.js47
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-10.js52
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-11.js25
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-12.js162
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-13.js39
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-14.js52
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-15.js78
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-16.js81
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-17.js69
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-18.js100
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-19.js93
-rw-r--r--devtools/server/tests/xpcshell/test_stepping-with-skip-breakpoints.js84
-rw-r--r--devtools/server/tests/xpcshell/test_symbolactor.js53
-rw-r--r--devtools/server/tests/xpcshell/test_symbols-01.js50
-rw-r--r--devtools/server/tests/xpcshell/test_symbols-02.js44
-rw-r--r--devtools/server/tests/xpcshell/test_threadlifetime-01.js56
-rw-r--r--devtools/server/tests/xpcshell/test_threadlifetime-02.js73
-rw-r--r--devtools/server/tests/xpcshell/test_threadlifetime-04.js58
-rw-r--r--devtools/server/tests/xpcshell/test_unsafeDereference.js130
-rw-r--r--devtools/server/tests/xpcshell/test_wasm_source-01.js143
-rw-r--r--devtools/server/tests/xpcshell/test_watchpoint-01.js197
-rw-r--r--devtools/server/tests/xpcshell/test_watchpoint-02.js223
-rw-r--r--devtools/server/tests/xpcshell/test_watchpoint-03.js72
-rw-r--r--devtools/server/tests/xpcshell/test_watchpoint-04.js78
-rw-r--r--devtools/server/tests/xpcshell/test_watchpoint-05.js113
-rw-r--r--devtools/server/tests/xpcshell/test_webext_apis.js162
-rw-r--r--devtools/server/tests/xpcshell/test_webextension_descriptor.js141
-rw-r--r--devtools/server/tests/xpcshell/test_xpcshell_debugging.js90
-rw-r--r--devtools/server/tests/xpcshell/testactors.js242
-rw-r--r--devtools/server/tests/xpcshell/webextension-helpers.js197
-rw-r--r--devtools/server/tests/xpcshell/xpcshell.toml436
-rw-r--r--devtools/server/tests/xpcshell/xpcshell_debugging_script.js11
-rw-r--r--devtools/server/tracer/moz.build14
-rw-r--r--devtools/server/tracer/tests/browser/Worker.tracer.js10
-rw-r--r--devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js36
-rw-r--r--devtools/server/tracer/tests/browser/browser.toml11
-rw-r--r--devtools/server/tracer/tests/browser/browser_document_tracer.js68
-rw-r--r--devtools/server/tracer/tests/browser/browser_worker_tracer.js68
-rw-r--r--devtools/server/tracer/tests/xpcshell/test_tracer.js240
-rw-r--r--devtools/server/tracer/tests/xpcshell/xpcshell.toml6
-rw-r--r--devtools/server/tracer/tracer.jsm798
721 files changed, 124792 insertions, 0 deletions
diff --git a/devtools/server/actors/accessibility/accessibility.js b/devtools/server/actors/accessibility/accessibility.js
new file mode 100644
index 0000000000..6bdf0e9f32
--- /dev/null
+++ b/devtools/server/actors/accessibility/accessibility.js
@@ -0,0 +1,130 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ accessibilitySpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+loader.lazyRequireGetter(
+ this,
+ "AccessibleWalkerActor",
+ "resource://devtools/server/actors/accessibility/walker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "SimulatorActor",
+ "resource://devtools/server/actors/accessibility/simulator.js",
+ true
+);
+
+/**
+ * The AccessibilityActor is a top level container actor that initializes
+ * accessible walker and is the top-most point of interaction for accessibility
+ * tools UI for a top level content process.
+ */
+class AccessibilityActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, accessibilitySpec);
+ // This event is fired when accessibility service is initialized or shut
+ // down. "init" and "shutdown" events are only relayed when the enabled
+ // state matches the event (e.g. the event came from the same process as
+ // the actor).
+ Services.obs.addObserver(this, "a11y-init-or-shutdown");
+ this.targetActor = targetActor;
+ }
+
+ getTraits() {
+ // The traits are used to know if accessibility actors support particular
+ // API on the server side.
+ return {
+ // @backward-compat { version 84 } Fixed on the server by Bug 1654956.
+ tabbingOrder: true,
+ };
+ }
+
+ bootstrap() {
+ return {
+ enabled: this.enabled,
+ };
+ }
+
+ get enabled() {
+ return Services.appinfo.accessibilityEnabled;
+ }
+
+ /**
+ * Observe Accessibility service init and shutdown events. It relays these
+ * events to AccessibilityFront if the event is fired for the a11y service
+ * that lives in the same process.
+ *
+ * @param {null} subject
+ * Not used.
+ * @param {String} topic
+ * Name of the a11y service event: "a11y-init-or-shutdown".
+ * @param {String} data
+ * "0" corresponds to shutdown and "1" to init.
+ */
+ observe(subject, topic, data) {
+ const enabled = data === "1";
+ if (enabled && this.enabled) {
+ this.emit("init");
+ } else if (!enabled && !this.enabled) {
+ if (this.walker) {
+ this.walker.reset();
+ }
+
+ this.emit("shutdown");
+ }
+ }
+
+ /**
+ * Get or create AccessibilityWalker actor, similar to WalkerActor.
+ *
+ * @return {Object}
+ * AccessibleWalkerActor for the current tab.
+ */
+ getWalker() {
+ if (!this.walker) {
+ this.walker = new AccessibleWalkerActor(this.conn, this.targetActor);
+ this.manage(this.walker);
+ }
+ return this.walker;
+ }
+
+ /**
+ * Get or create Simulator actor, managed by AccessibilityActor,
+ * only if webrender is enabled. Simulator applies color filters on an entire
+ * viewport. This needs to be done using webrender and not an SVG
+ * <feColorMatrix> since it is accelerated and scrolling with filter applied
+ * needs to be smooth (Bug1431466).
+ *
+ * @return {Object|null}
+ * SimulatorActor for the current tab.
+ */
+ getSimulator() {
+ if (!this.simulator) {
+ this.simulator = new SimulatorActor(this.conn, this.targetActor);
+ this.manage(this.simulator);
+ }
+
+ return this.simulator;
+ }
+
+ /**
+ * Destroy accessibility actor. This method also shutsdown accessibility
+ * service if possible.
+ */
+ async destroy() {
+ super.destroy();
+ Services.obs.removeObserver(this, "a11y-init-or-shutdown");
+ this.walker = null;
+ this.targetActor = null;
+ }
+}
+
+exports.AccessibilityActor = AccessibilityActor;
diff --git a/devtools/server/actors/accessibility/accessible.js b/devtools/server/actors/accessibility/accessible.js
new file mode 100644
index 0000000000..1866d0a91b
--- /dev/null
+++ b/devtools/server/actors/accessibility/accessible.js
@@ -0,0 +1,675 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ accessibleSpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+const {
+ accessibility: { AUDIT_TYPE },
+} = require("resource://devtools/shared/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "getContrastRatioFor",
+ "resource://devtools/server/actors/accessibility/audit/contrast.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "auditKeyboard",
+ "resource://devtools/server/actors/accessibility/audit/keyboard.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "auditTextLabel",
+ "resource://devtools/server/actors/accessibility/audit/text-label.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isDefunct",
+ "resource://devtools/server/actors/utils/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "findCssSelector",
+ "resource://devtools/shared/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "events",
+ "resource://devtools/shared/event-emitter.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "getBounds",
+ "resource://devtools/server/actors/highlighters/utils/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isFrameWithChildTarget",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+const lazy = {};
+loader.lazyGetter(
+ lazy,
+ "ContentDOMReference",
+ () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ContentDOMReference.sys.mjs",
+ {
+ // ContentDOMReference needs to be retrieved from the shared global
+ // since it is a shared singleton.
+ loadInDevToolsLoader: false,
+ }
+ ).ContentDOMReference
+);
+
+const RELATIONS_TO_IGNORE = new Set([
+ Ci.nsIAccessibleRelation.RELATION_CONTAINING_APPLICATION,
+ Ci.nsIAccessibleRelation.RELATION_CONTAINING_TAB_PANE,
+ Ci.nsIAccessibleRelation.RELATION_CONTAINING_WINDOW,
+ Ci.nsIAccessibleRelation.RELATION_PARENT_WINDOW_OF,
+ Ci.nsIAccessibleRelation.RELATION_SUBWINDOW_OF,
+]);
+
+const nsIAccessibleRole = Ci.nsIAccessibleRole;
+const TEXT_ROLES = new Set([
+ nsIAccessibleRole.ROLE_TEXT_LEAF,
+ nsIAccessibleRole.ROLE_STATICTEXT,
+]);
+
+const STATE_DEFUNCT = Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT;
+const CSS_TEXT_SELECTOR = "#text";
+
+/**
+ * Get node inforamtion such as nodeType and the unique CSS selector for the node.
+ * @param {DOMNode} node
+ * Node for which to get the information.
+ * @return {Object}
+ * Information about the type of the node and how to locate it.
+ */
+function getNodeDescription(node) {
+ if (!node || Cu.isDeadWrapper(node)) {
+ return { nodeType: undefined, nodeCssSelector: "" };
+ }
+
+ const { nodeType } = node;
+ return {
+ nodeType,
+ // If node is a text node, we find a unique CSS selector for its parent and add a
+ // CSS_TEXT_SELECTOR postfix to indicate that it's a text node.
+ nodeCssSelector:
+ nodeType === Node.TEXT_NODE
+ ? `${findCssSelector(node.parentNode)}${CSS_TEXT_SELECTOR}`
+ : findCssSelector(node),
+ };
+}
+
+/**
+ * Get a snapshot of the nsIAccessible object including its subtree. None of the subtree
+ * queried here is cached via accessible walker's refMap.
+ * @param {nsIAccessible} acc
+ * Accessible object to take a snapshot of.
+ * @param {nsIAccessibilityService} a11yService
+ * Accessibility service instance in the current process, used to get localized
+ * string representation of various accessible properties.
+ * @param {WindowGlobalTargetActor} targetActor
+ * @return {JSON}
+ * JSON snapshot of the accessibility tree with root at current accessible.
+ */
+function getSnapshot(acc, a11yService, targetActor) {
+ if (isDefunct(acc)) {
+ return {
+ states: [a11yService.getStringStates(0, STATE_DEFUNCT)],
+ };
+ }
+
+ const actions = [];
+ for (let i = 0; i < acc.actionCount; i++) {
+ actions.push(acc.getActionDescription(i));
+ }
+
+ const attributes = {};
+ if (acc.attributes) {
+ for (const { key, value } of acc.attributes.enumerate()) {
+ attributes[key] = value;
+ }
+ }
+
+ const state = {};
+ const extState = {};
+ acc.getState(state, extState);
+ const states = [...a11yService.getStringStates(state.value, extState.value)];
+
+ const children = [];
+ for (let child = acc.firstChild; child; child = child.nextSibling) {
+ // Ignore children from different documents when we have targets for every documents.
+ if (
+ targetActor.ignoreSubFrames &&
+ child.DOMNode.ownerDocument !== targetActor.contentDocument
+ ) {
+ continue;
+ }
+ children.push(getSnapshot(child, a11yService, targetActor));
+ }
+
+ const { nodeType, nodeCssSelector } = getNodeDescription(acc.DOMNode);
+ const snapshot = {
+ name: acc.name,
+ role: getStringRole(acc, a11yService),
+ actions,
+ value: acc.value,
+ nodeCssSelector,
+ nodeType,
+ description: acc.description,
+ keyboardShortcut: acc.accessKey || acc.keyboardShortcut,
+ childCount: acc.childCount,
+ indexInParent: acc.indexInParent,
+ states,
+ children,
+ attributes,
+ };
+ const useChildTargetToFetchChildren =
+ acc.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME &&
+ isFrameWithChildTarget(targetActor, acc.DOMNode);
+ if (useChildTargetToFetchChildren) {
+ snapshot.useChildTargetToFetchChildren = useChildTargetToFetchChildren;
+ snapshot.childCount = 1;
+ snapshot.contentDOMReference = lazy.ContentDOMReference.get(acc.DOMNode);
+ }
+
+ return snapshot;
+}
+
+/**
+ * Get a string indicating the role of the nsIAccessible object.
+ * An ARIA role token will be returned unless the role can't be mapped to an
+ * ARIA role (e.g. <iframe>), in which case a Gecko role string will be
+ * returned.
+ * @param {nsIAccessible} acc
+ * Accessible object to take a snapshot of.
+ * @param {nsIAccessibilityService} a11yService
+ * Accessibility service instance in the current process, used to get localized
+ * string representation of various accessible properties.
+ * @return String
+ */
+function getStringRole(acc, a11yService) {
+ let role = acc.computedARIARole;
+ if (!role) {
+ // We couldn't map to an ARIA role, so use a Gecko role string.
+ role = a11yService.getStringRole(acc.role);
+ }
+ return role;
+}
+
+/**
+ * The AccessibleActor provides information about a given accessible object: its
+ * role, name, states, etc.
+ */
+class AccessibleActor extends Actor {
+ constructor(walker, rawAccessible) {
+ super(walker.conn, accessibleSpec);
+ this.walker = walker;
+ this.rawAccessible = rawAccessible;
+
+ /**
+ * Indicates if the raw accessible is no longer alive.
+ *
+ * @return Boolean
+ */
+ Object.defineProperty(this, "isDefunct", {
+ get() {
+ const defunct = isDefunct(this.rawAccessible);
+ if (defunct) {
+ delete this.isDefunct;
+ this.isDefunct = true;
+ return this.isDefunct;
+ }
+
+ return defunct;
+ },
+ configurable: true,
+ });
+ }
+
+ destroy() {
+ super.destroy();
+ this.walker = null;
+ this.rawAccessible = null;
+ }
+
+ get role() {
+ if (this.isDefunct) {
+ return null;
+ }
+ return getStringRole(this.rawAccessible, this.walker.a11yService);
+ }
+
+ get name() {
+ if (this.isDefunct) {
+ return null;
+ }
+ return this.rawAccessible.name;
+ }
+
+ get value() {
+ if (this.isDefunct) {
+ return null;
+ }
+ return this.rawAccessible.value;
+ }
+
+ get description() {
+ if (this.isDefunct) {
+ return null;
+ }
+ return this.rawAccessible.description;
+ }
+
+ get keyboardShortcut() {
+ if (this.isDefunct) {
+ return null;
+ }
+ // Gecko accessibility exposes two key bindings: Accessible::AccessKey and
+ // Accessible::KeyboardShortcut. The former is used for accesskey, where the latter
+ // is used for global shortcuts defined by XUL menu items, etc. Here - do what the
+ // Windows implementation does: try AccessKey first, and if that's empty, use
+ // KeyboardShortcut.
+ return this.rawAccessible.accessKey || this.rawAccessible.keyboardShortcut;
+ }
+
+ get childCount() {
+ if (this.isDefunct) {
+ return 0;
+ }
+ // In case of a remote frame declare at least one child (the #document
+ // element) so that they can be expanded.
+ if (this.useChildTargetToFetchChildren) {
+ return 1;
+ }
+
+ return this.rawAccessible.childCount;
+ }
+
+ get domNodeType() {
+ if (this.isDefunct) {
+ return 0;
+ }
+ return this.rawAccessible.DOMNode ? this.rawAccessible.DOMNode.nodeType : 0;
+ }
+
+ get parentAcc() {
+ if (this.isDefunct) {
+ return null;
+ }
+ return this.walker.addRef(this.rawAccessible.parent);
+ }
+
+ children() {
+ const children = [];
+ if (this.isDefunct) {
+ return children;
+ }
+
+ for (
+ let child = this.rawAccessible.firstChild;
+ child;
+ child = child.nextSibling
+ ) {
+ children.push(this.walker.addRef(child));
+ }
+ return children;
+ }
+
+ get indexInParent() {
+ if (this.isDefunct) {
+ return -1;
+ }
+
+ try {
+ return this.rawAccessible.indexInParent;
+ } catch (e) {
+ // Accessible is dead.
+ return -1;
+ }
+ }
+
+ get actions() {
+ const actions = [];
+ if (this.isDefunct) {
+ return actions;
+ }
+
+ for (let i = 0; i < this.rawAccessible.actionCount; i++) {
+ actions.push(this.rawAccessible.getActionDescription(i));
+ }
+ return actions;
+ }
+
+ get states() {
+ if (this.isDefunct) {
+ return [];
+ }
+
+ const state = {};
+ const extState = {};
+ this.rawAccessible.getState(state, extState);
+ return [
+ ...this.walker.a11yService.getStringStates(state.value, extState.value),
+ ];
+ }
+
+ get attributes() {
+ if (this.isDefunct || !this.rawAccessible.attributes) {
+ return {};
+ }
+
+ const attributes = {};
+ for (const { key, value } of this.rawAccessible.attributes.enumerate()) {
+ attributes[key] = value;
+ }
+
+ return attributes;
+ }
+
+ get bounds() {
+ if (this.isDefunct) {
+ return null;
+ }
+
+ let x = {},
+ y = {},
+ w = {},
+ h = {};
+ try {
+ this.rawAccessible.getBoundsInCSSPixels(x, y, w, h);
+ x = x.value;
+ y = y.value;
+ w = w.value;
+ h = h.value;
+ } catch (e) {
+ return null;
+ }
+
+ // Check if accessible bounds are invalid.
+ const left = x,
+ right = x + w,
+ top = y,
+ bottom = y + h;
+ if (left === right || top === bottom) {
+ return null;
+ }
+
+ return { x, y, w, h };
+ }
+
+ async getRelations() {
+ const relationObjects = [];
+ if (this.isDefunct) {
+ return relationObjects;
+ }
+
+ const relations = [
+ ...this.rawAccessible.getRelations().enumerate(Ci.nsIAccessibleRelation),
+ ];
+ if (relations.length === 0) {
+ return relationObjects;
+ }
+
+ const doc = await this.walker.getDocument();
+ if (this.isDestroyed()) {
+ // This accessible actor is destroyed.
+ return relationObjects;
+ }
+ relations.forEach(relation => {
+ if (RELATIONS_TO_IGNORE.has(relation.relationType)) {
+ return;
+ }
+
+ const type = this.walker.a11yService.getStringRelationType(
+ relation.relationType
+ );
+ const targets = [...relation.getTargets().enumerate(Ci.nsIAccessible)];
+ let relationObject;
+ for (const target of targets) {
+ let targetAcc;
+ try {
+ targetAcc = this.walker.attachAccessible(target, doc.rawAccessible);
+ } catch (e) {
+ // Target is not available.
+ }
+
+ if (targetAcc) {
+ if (!relationObject) {
+ relationObject = { type, targets: [] };
+ }
+
+ relationObject.targets.push(targetAcc);
+ }
+ }
+
+ if (relationObject) {
+ relationObjects.push(relationObject);
+ }
+ });
+
+ return relationObjects;
+ }
+
+ get useChildTargetToFetchChildren() {
+ if (this.isDefunct) {
+ return false;
+ }
+
+ return (
+ this.rawAccessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME &&
+ isFrameWithChildTarget(
+ this.walker.targetActor,
+ this.rawAccessible.DOMNode
+ )
+ );
+ }
+
+ form() {
+ return {
+ actor: this.actorID,
+ role: this.role,
+ name: this.name,
+ useChildTargetToFetchChildren: this.useChildTargetToFetchChildren,
+ childCount: this.childCount,
+ checks: this._lastAudit,
+ };
+ }
+
+ /**
+ * Provide additional (full) information about the accessible object that is
+ * otherwise missing from the form.
+ *
+ * @return {Object}
+ * Object that contains accessible object information such as states,
+ * actions, attributes, etc.
+ */
+ hydrate() {
+ return {
+ value: this.value,
+ description: this.description,
+ keyboardShortcut: this.keyboardShortcut,
+ domNodeType: this.domNodeType,
+ indexInParent: this.indexInParent,
+ states: this.states,
+ actions: this.actions,
+ attributes: this.attributes,
+ };
+ }
+
+ _isValidTextLeaf(rawAccessible) {
+ return (
+ !isDefunct(rawAccessible) &&
+ TEXT_ROLES.has(rawAccessible.role) &&
+ rawAccessible.name &&
+ !!rawAccessible.name.trim().length
+ );
+ }
+
+ /**
+ * Calculate the contrast ratio of the given accessible.
+ */
+ async _getContrastRatio() {
+ if (!this._isValidTextLeaf(this.rawAccessible)) {
+ return null;
+ }
+
+ const { bounds } = this;
+ if (!bounds) {
+ return null;
+ }
+
+ const { DOMNode: rawNode } = this.rawAccessible;
+ const win = rawNode.ownerGlobal;
+
+ // Keep the reference to the walker actor in case the actor gets destroyed
+ // during the colour contrast ratio calculation.
+ const { walker } = this;
+ await walker.clearStyles(win);
+ const contrastRatio = await getContrastRatioFor(rawNode.parentNode, {
+ bounds: getBounds(win, bounds),
+ win,
+ appliedColorMatrix: this.walker.colorMatrix,
+ });
+
+ if (this.isDestroyed()) {
+ // This accessible actor is destroyed.
+ return null;
+ }
+ await walker.restoreStyles(win);
+
+ return contrastRatio;
+ }
+
+ /**
+ * Run an accessibility audit for a given audit type.
+ * @param {String} type
+ * Type of an audit (Check AUDIT_TYPE in devtools/shared/constants
+ * to see available audit types).
+ *
+ * @return {null|Object}
+ * Object that contains accessible audit data for a given type or null
+ * if there's nothing to report for this accessible.
+ */
+ _getAuditByType(type) {
+ switch (type) {
+ case AUDIT_TYPE.CONTRAST:
+ return this._getContrastRatio();
+ case AUDIT_TYPE.KEYBOARD:
+ // Determine if keyboard accessibility is lacking where it is necessary.
+ return auditKeyboard(this.rawAccessible);
+ case AUDIT_TYPE.TEXT_LABEL:
+ // Determine if text alternative is missing for an accessible where it
+ // is necessary.
+ return auditTextLabel(this.rawAccessible);
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Audit the state of the accessible object.
+ *
+ * @param {Object} options
+ * Options for running audit, may include:
+ * - types: Array of audit types to be performed during audit.
+ *
+ * @return {Object|null}
+ * Audit results for the accessible object.
+ */
+ audit(options = {}) {
+ if (this._auditing) {
+ return this._auditing;
+ }
+
+ const { types } = options;
+ let auditTypes = Object.values(AUDIT_TYPE);
+ if (types && types.length) {
+ auditTypes = auditTypes.filter(auditType => types.includes(auditType));
+ }
+
+ // For some reason keyboard checks for focus styling affect values (that are
+ // used by other types of checks (text names and values)) returned by
+ // accessible objects. This happens only when multiple checks are run at the
+ // same time (asynchronously) and the audit might return unexpected
+ // failures. We thus split the execution of the checks into two parts, first
+ // performing keyboard checks and only after the rest of the checks. See bug
+ // 1594743 for more detail.
+ let keyboardAuditResult;
+ const keyboardAuditIndex = auditTypes.indexOf(AUDIT_TYPE.KEYBOARD);
+ if (keyboardAuditIndex > -1) {
+ // If we are performing a keyboard audit, remove its value from the
+ // complete list and run it.
+ auditTypes.splice(keyboardAuditIndex, 1);
+ keyboardAuditResult = this._getAuditByType(AUDIT_TYPE.KEYBOARD);
+ }
+
+ this._auditing = Promise.resolve(keyboardAuditResult)
+ .then(keyboardResult => {
+ const audits = auditTypes.map(auditType =>
+ this._getAuditByType(auditType)
+ );
+
+ // If we are also performing a keyboard audit, add its type and its
+ // result back to the complete list of audits.
+ if (keyboardAuditIndex > -1) {
+ auditTypes.splice(keyboardAuditIndex, 0, AUDIT_TYPE.KEYBOARD);
+ audits.splice(keyboardAuditIndex, 0, keyboardResult);
+ }
+
+ return Promise.all(audits);
+ })
+ .then(results => {
+ if (this.isDefunct || this.isDestroyed()) {
+ return null;
+ }
+
+ const audit = results.reduce((auditResults, result, index) => {
+ auditResults[auditTypes[index]] = result;
+ return auditResults;
+ }, {});
+ this._lastAudit = this._lastAudit || {};
+ Object.assign(this._lastAudit, audit);
+ events.emit(this, "audited", audit);
+
+ return audit;
+ })
+ .catch(error => {
+ if (!this.isDefunct && !this.isDestroyed()) {
+ throw error;
+ }
+ return null;
+ })
+ .finally(() => {
+ this._auditing = null;
+ });
+
+ return this._auditing;
+ }
+
+ snapshot() {
+ return getSnapshot(
+ this.rawAccessible,
+ this.walker.a11yService,
+ this.walker.targetActor
+ );
+ }
+}
+
+exports.AccessibleActor = AccessibleActor;
diff --git a/devtools/server/actors/accessibility/audit/contrast.js b/devtools/server/actors/accessibility/audit/contrast.js
new file mode 100644
index 0000000000..68e7b497f8
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/contrast.js
@@ -0,0 +1,306 @@
+/* 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,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getCurrentZoom",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "addPseudoClassLock",
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "removePseudoClassLock",
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getContrastRatioAgainstBackground",
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getTextProperties",
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "DevToolsWorker",
+ "resource://devtools/shared/worker/worker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "InspectorActorUtils",
+ "resource://devtools/server/actors/inspector/utils.js"
+);
+
+const WORKER_URL = "resource://devtools/server/actors/accessibility/worker.js";
+const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted";
+const {
+ LARGE_TEXT: { BOLD_LARGE_TEXT_MIN_PIXELS, LARGE_TEXT_MIN_PIXELS },
+} = require("resource://devtools/shared/accessibility.js");
+
+loader.lazyGetter(this, "worker", () => new DevToolsWorker(WORKER_URL));
+
+/**
+ * Get canvas rendering context for the current target window bound by the bounds of the
+ * accessible objects.
+ * @param {Object} win
+ * Current target window.
+ * @param {Object} bounds
+ * Bounds for the accessible object.
+ * @param {Object} zoom
+ * Current zoom level for the window.
+ * @param {Object} scale
+ * Scale value to scale down the drawn image.
+ * @param {null|DOMNode} node
+ * If not null, a node that corresponds to the accessible object to be used to
+ * make its text color transparent.
+ * @return {CanvasRenderingContext2D}
+ * Canvas rendering context for the current window.
+ */
+function getImageCtx(win, bounds, zoom, scale, node) {
+ const doc = win.document;
+ const canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+
+ const { left, top, width, height } = bounds;
+ canvas.width = width * zoom * scale;
+ canvas.height = height * zoom * scale;
+ const ctx = canvas.getContext("2d", { alpha: false });
+ ctx.imageSmoothingEnabled = false;
+ ctx.scale(scale, scale);
+
+ // If node is passed, make its color related text properties invisible.
+ if (node) {
+ addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
+ }
+
+ ctx.drawWindow(
+ win,
+ left * zoom,
+ top * zoom,
+ width * zoom,
+ height * zoom,
+ "#fff",
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS
+ );
+
+ // Restore all inline styling.
+ if (node) {
+ removePseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
+ }
+
+ return ctx;
+}
+
+/**
+ * Calculate the transformed RGBA when a color matrix is set in docShell by
+ * multiplying the color matrix with the RGBA vector.
+ *
+ * @param {Array} rgba
+ * Original RGBA array which we want to transform.
+ * @param {Array} colorMatrix
+ * Flattened 4x5 color matrix that is set in docShell.
+ * A 4x5 matrix of the form:
+ * 1 2 3 4 5
+ * 6 7 8 9 10
+ * 11 12 13 14 15
+ * 16 17 18 19 20
+ * will be set in docShell as:
+ * [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20]
+ * @return {Array}
+ * Transformed RGBA after the color matrix is multiplied with the original RGBA.
+ */
+function getTransformedRGBA(rgba, colorMatrix) {
+ const transformedRGBA = [0, 0, 0, 0];
+
+ // Only use the first four columns of the color matrix corresponding to R, G, B and A
+ // color channels respectively. The fifth column is a fixed offset that does not need
+ // to be considered for the matrix multiplication. We end up multiplying a 4x4 color
+ // matrix with a 4x1 RGBA vector.
+ for (let i = 0; i < 16; i++) {
+ const row = i % 4;
+ const col = Math.floor(i / 4);
+ transformedRGBA[row] += colorMatrix[i] * rgba[col];
+ }
+
+ return transformedRGBA;
+}
+
+/**
+ * Find RGBA or a range of RGBAs for the background pixels under the text.
+ *
+ * @param {DOMNode} node
+ * Node for which we want to get the background color data.
+ * @param {Object} options
+ * - bounds {Object}
+ * Bounds for the accessible object.
+ * - win {Object}
+ * Target window.
+ * - size {Number}
+ * Font size of the selected text node
+ * - isBoldText {Boolean}
+ * True if selected text node is bold
+ * @return {Object}
+ * Object with one or more of the following RGBA fields: value, min, max
+ */
+function getBackgroundFor(node, { win, bounds, size, isBoldText }) {
+ const zoom = 1 / getCurrentZoom(win);
+ // When calculating colour contrast, we traverse image data for text nodes that are
+ // drawn both with and without transparent text. Image data arrays are typically really
+ // big. In cases when the font size is fairly large or when the page is zoomed in image
+ // data is especially large (retrieving it and/or traversing it takes significant amount
+ // of time). Here we optimize the size of the image data by scaling down the drawn nodes
+ // to a size where their text size equals either BOLD_LARGE_TEXT_MIN_PIXELS or
+ // LARGE_TEXT_MIN_PIXELS (lower threshold for large text size) depending on the font
+ // weight.
+ //
+ // IMPORTANT: this optimization, in some cases where background colour is non-uniform
+ // (gradient or image), can result in small (not noticeable) blending of the background
+ // colours. In turn this might affect the reported values of the contrast ratio. The
+ // delta is fairly small (<0.1) to noticeably skew the results.
+ //
+ // NOTE: this optimization does not help in cases where contrast is being calculated for
+ // nodes with a lot of text.
+ let scale =
+ ((isBoldText ? BOLD_LARGE_TEXT_MIN_PIXELS : LARGE_TEXT_MIN_PIXELS) / size) *
+ zoom;
+ // We do not need to scale the images if the font is smaller than large or if the page
+ // is zoomed out (scaling in this case would've been scaling up).
+ scale = scale > 1 ? 1 : scale;
+
+ const textContext = getImageCtx(win, bounds, zoom, scale);
+ const backgroundContext = getImageCtx(win, bounds, zoom, scale, node);
+
+ const { data: dataText } = textContext.getImageData(
+ 0,
+ 0,
+ bounds.width * scale,
+ bounds.height * scale
+ );
+ const { data: dataBackground } = backgroundContext.getImageData(
+ 0,
+ 0,
+ bounds.width * scale,
+ bounds.height * scale
+ );
+
+ return worker.performTask(
+ "getBgRGBA",
+ {
+ dataTextBuf: dataText.buffer,
+ dataBackgroundBuf: dataBackground.buffer,
+ },
+ [dataText.buffer, dataBackground.buffer]
+ );
+}
+
+/**
+ * Calculates the contrast ratio of the referenced DOM node.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to calculate the contrast ratio.
+ * @param {Object} options
+ * - bounds {Object}
+ * Bounds for the accessible object.
+ * - win {Object}
+ * Target window.
+ * - appliedColorMatrix {Array|null}
+ * Simulation color matrix applied to
+ * to the viewport, if it exists.
+ * @return {Object}
+ * An object that may contain one or more of the following fields: error,
+ * isLargeText, value, min, max values for contrast.
+ */
+async function getContrastRatioFor(node, options = {}) {
+ const computedStyle = CssLogic.getComputedStyle(node);
+ const props = computedStyle ? getTextProperties(computedStyle) : null;
+
+ if (!props) {
+ return {
+ error: true,
+ };
+ }
+
+ const { isLargeText, isBoldText, size, opacity } = props;
+ const { appliedColorMatrix } = options;
+ const color = appliedColorMatrix
+ ? getTransformedRGBA(props.color, appliedColorMatrix)
+ : props.color;
+ let rgba = await getBackgroundFor(node, {
+ ...options,
+ isBoldText,
+ size,
+ });
+
+ if (!rgba) {
+ // Fallback (original) contrast calculation algorithm. It tries to get the
+ // closest background colour for the node and use it to calculate contrast.
+ const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node);
+ const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node);
+
+ if (backgroundImage !== "none") {
+ // Both approaches failed, at this point we don't have a better one yet.
+ return {
+ error: true,
+ };
+ }
+
+ let { r, g, b, a } = InspectorUtils.colorToRGBA(backgroundColor);
+ // 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).
+ if (opacity < 1) {
+ a = opacity * a;
+ }
+
+ return getContrastRatioAgainstBackground(
+ {
+ value: appliedColorMatrix
+ ? getTransformedRGBA([r, g, b, a], appliedColorMatrix)
+ : [r, g, b, a],
+ },
+ {
+ color,
+ isLargeText,
+ }
+ );
+ }
+
+ if (appliedColorMatrix) {
+ rgba = rgba.value
+ ? {
+ value: getTransformedRGBA(rgba.value, appliedColorMatrix),
+ }
+ : {
+ min: getTransformedRGBA(rgba.min, appliedColorMatrix),
+ max: getTransformedRGBA(rgba.max, appliedColorMatrix),
+ };
+ }
+
+ return getContrastRatioAgainstBackground(rgba, {
+ color,
+ isLargeText,
+ });
+}
+
+exports.getContrastRatioFor = getContrastRatioFor;
+exports.getBackgroundFor = getBackgroundFor;
diff --git a/devtools/server/actors/accessibility/audit/keyboard.js b/devtools/server/actors/accessibility/audit/keyboard.js
new file mode 100644
index 0000000000..d1b13dbbf6
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/keyboard.js
@@ -0,0 +1,514 @@
+/* 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,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getCSSStyleRules",
+ "resource://devtools/shared/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "nodeConstants",
+ "resource://devtools/shared/dom-node-constants.js"
+);
+loader.lazyRequireGetter(
+ this,
+ ["isDefunct", "getAriaRoles"],
+ "resource://devtools/server/actors/utils/accessibility.js",
+ true
+);
+
+const {
+ accessibility: {
+ AUDIT_TYPE: { KEYBOARD },
+ ISSUE_TYPE: {
+ [KEYBOARD]: {
+ FOCUSABLE_NO_SEMANTICS,
+ FOCUSABLE_POSITIVE_TABINDEX,
+ INTERACTIVE_NO_ACTION,
+ INTERACTIVE_NOT_FOCUSABLE,
+ MOUSE_INTERACTIVE_ONLY,
+ NO_FOCUS_VISIBLE,
+ },
+ },
+ SCORES: { FAIL, WARNING },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+// Specified by the author CSS rule type.
+const STYLE_RULE = 1;
+
+// Accessible action for showing long description.
+const CLICK_ACTION = "click";
+
+/**
+ * Focus specific pseudo classes that the keyboard audit simulates to determine
+ * focus styling.
+ */
+const FOCUS_PSEUDO_CLASS = ":focus";
+const MOZ_FOCUSRING_PSEUDO_CLASS = ":-moz-focusring";
+
+const KEYBOARD_FOCUSABLE_ROLES = new Set([
+ Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
+ Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
+ Ci.nsIAccessibleRole.ROLE_COMBOBOX,
+ Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
+ Ci.nsIAccessibleRole.ROLE_ENTRY,
+ Ci.nsIAccessibleRole.ROLE_LINK,
+ Ci.nsIAccessibleRole.ROLE_LISTBOX,
+ Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
+ Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
+ Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
+ Ci.nsIAccessibleRole.ROLE_SLIDER,
+ Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
+ Ci.nsIAccessibleRole.ROLE_SUMMARY,
+ Ci.nsIAccessibleRole.ROLE_SWITCH,
+ Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
+]);
+
+const INTERACTIVE_ROLES = new Set([
+ ...KEYBOARD_FOCUSABLE_ROLES,
+ Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
+ Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
+ Ci.nsIAccessibleRole.ROLE_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_OPTION,
+ Ci.nsIAccessibleRole.ROLE_OUTLINE,
+ Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
+ Ci.nsIAccessibleRole.ROLE_PAGETAB,
+ Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
+]);
+
+const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([
+ // If article is focusable, we can assume it is inside a feed.
+ Ci.nsIAccessibleRole.ROLE_ARTICLE,
+ // Column header can be focusable.
+ Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
+ Ci.nsIAccessibleRole.ROLE_GRID_CELL,
+ Ci.nsIAccessibleRole.ROLE_MENUBAR,
+ Ci.nsIAccessibleRole.ROLE_MENUPOPUP,
+ Ci.nsIAccessibleRole.ROLE_PAGETABLIST,
+ // Row header can be focusable.
+ Ci.nsIAccessibleRole.ROLE_ROWHEADER,
+ Ci.nsIAccessibleRole.ROLE_SCROLLBAR,
+ Ci.nsIAccessibleRole.ROLE_SEPARATOR,
+ Ci.nsIAccessibleRole.ROLE_TOOLBAR,
+]);
+
+/**
+ * Determine if a node is dead or is not an element node.
+ *
+ * @param {DOMNode} node
+ * Node to be tested for validity.
+ *
+ * @returns {Boolean}
+ * True if the node is either dead or is not an element node.
+ */
+function isInvalidNode(node) {
+ return (
+ !node ||
+ Cu.isDeadWrapper(node) ||
+ node.nodeType !== nodeConstants.ELEMENT_NODE ||
+ !node.ownerGlobal
+ );
+}
+
+/**
+ * Determine if accessible is focusable with the keyboard.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible for which to determine if it is keyboard focusable.
+ *
+ * @returns {Boolean}
+ * True if focusable with the keyboard.
+ */
+function isKeyboardFocusable(accessible) {
+ const state = {};
+ accessible.getState(state, {});
+ // State will be focusable even if the tabindex is negative.
+ return (
+ state.value & Ci.nsIAccessibleStates.STATE_FOCUSABLE &&
+ // Platform accessibility will still report STATE_FOCUSABLE even with the
+ // tabindex="-1" so we need to check that it is >= 0 to be considered
+ // keyboard focusable.
+ accessible.DOMNode.tabIndex > -1
+ );
+}
+
+/**
+ * Determine if a current node has focus specific styling by applying a
+ * focus-related pseudo class (such as :focus or :-moz-focusring) to a focusable
+ * node.
+ *
+ * @param {DOMNode} focusableNode
+ * Node to apply focus-related pseudo class to.
+ * @param {DOMNode} currentNode
+ * Node to be checked for having focus specific styling.
+ * @param {String} pseudoClass
+ * A focus related pseudo-class to be simulated for style comparison.
+ *
+ * @returns {Boolean}
+ * True if the currentNode has focus specific styling.
+ */
+function hasStylesForFocusRelatedPseudoClass(
+ focusableNode,
+ currentNode,
+ pseudoClass
+) {
+ const defaultRules = getCSSStyleRules(currentNode);
+
+ InspectorUtils.addPseudoClassLock(focusableNode, pseudoClass);
+
+ // Determine a set of properties that are specific to CSS rules that are only
+ // present when a focus related pseudo-class is locked in.
+ const tempRules = getCSSStyleRules(currentNode);
+ const properties = new Set();
+ for (const rule of tempRules) {
+ if (rule.type !== STYLE_RULE) {
+ continue;
+ }
+
+ if (!defaultRules.includes(rule)) {
+ for (let index = 0; index < rule.style.length; index++) {
+ properties.add(rule.style.item(index));
+ }
+ }
+ }
+
+ // If there are no focus specific CSS rules or properties, currentNode does
+ // node have any focus specific styling, we are done.
+ if (properties.size === 0) {
+ InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass);
+ return false;
+ }
+
+ // Determine values for properties that are focus specific.
+ const tempStyle = CssLogic.getComputedStyle(currentNode);
+ const focusStyle = {};
+ for (const name of properties.values()) {
+ focusStyle[name] = tempStyle.getPropertyValue(name);
+ }
+
+ InspectorUtils.removePseudoClassLock(focusableNode, pseudoClass);
+
+ // If values for focus specific properties are different from default style
+ // values, assume we have focus spefic styles for the currentNode.
+ const defaultStyle = CssLogic.getComputedStyle(currentNode);
+ for (const name of properties.values()) {
+ if (defaultStyle.getPropertyValue(name) !== focusStyle[name]) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Check if an element node (currentNode) has distinct focus styling. This
+ * function also takes into account a case when focus styling is applied to a
+ * descendant too.
+ *
+ * @param {DOMNode} focusableNode
+ * Node to apply focus-related pseudo class to.
+ * @param {DOMNode} currentNode
+ * Node to be checked for having focus specific styling.
+ *
+ * @returns {Boolean}
+ * True if the node or its descendant has distinct focus styling.
+ */
+function hasFocusStyling(focusableNode, currentNode) {
+ if (isInvalidNode(currentNode)) {
+ return false;
+ }
+
+ // Check if an element node has distinct :-moz-focusring styling.
+ const hasStylesForMozFocusring = hasStylesForFocusRelatedPseudoClass(
+ focusableNode,
+ currentNode,
+ MOZ_FOCUSRING_PSEUDO_CLASS
+ );
+ if (hasStylesForMozFocusring) {
+ return true;
+ }
+
+ // Check if an element node has distinct :focus styling.
+ const hasStylesForFocus = hasStylesForFocusRelatedPseudoClass(
+ focusableNode,
+ currentNode,
+ FOCUS_PSEUDO_CLASS
+ );
+ if (hasStylesForFocus) {
+ return true;
+ }
+
+ // If no element specific focus styles where found, check if its element
+ // children have them.
+ for (
+ let child = currentNode.firstElementChild;
+ child;
+ child = currentNode.nextnextElementSibling
+ ) {
+ if (hasFocusStyling(focusableNode, child)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * A rule that determines if a focusable accessible object has appropriate focus
+ * styling.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for being focusable and having focus
+ * styling.
+ *
+ * @return {null|Object}
+ * Null if accessible has keyboard focus styling, audit report object
+ * otherwise.
+ */
+function focusStyleRule(accessible) {
+ const { DOMNode } = accessible;
+ if (isInvalidNode(DOMNode)) {
+ return null;
+ }
+
+ // Ignore non-focusable elements.
+ if (!isKeyboardFocusable(accessible)) {
+ return null;
+ }
+
+ if (hasFocusStyling(DOMNode, DOMNode)) {
+ return null;
+ }
+
+ // If no browser or author focus styling was found, check if the node is a
+ // widget that is themed by platform native theme.
+ if (InspectorUtils.isElementThemed(DOMNode)) {
+ return null;
+ }
+
+ return { score: WARNING, issue: NO_FOCUS_VISIBLE };
+}
+
+/**
+ * A rule that determines if an interactive accessible has any associated
+ * accessible actions with it. If the element is interactive but and has no
+ * actions, assistive technology users will not be able to interact with it.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for being interactive and having accessible
+ * actions.
+ *
+ * @return {null|Object}
+ * Null if accessible is not interactive or if it is and it has
+ * accessible action associated with it, audit report object otherwise.
+ */
+function interactiveRule(accessible) {
+ if (!INTERACTIVE_ROLES.has(accessible.role)) {
+ return null;
+ }
+
+ if (accessible.actionCount > 0) {
+ return null;
+ }
+
+ return { score: FAIL, issue: INTERACTIVE_NO_ACTION };
+}
+
+/**
+ * A rule that determines if an interactive accessible is also focusable when
+ * not disabled.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for being interactive and being focusable
+ * when enabled.
+ *
+ * @return {null|Object}
+ * Null if accessible is not interactive or if it is and it is focusable
+ * when enabled, audit report object otherwise.
+ */
+function focusableRule(accessible) {
+ if (!KEYBOARD_FOCUSABLE_ROLES.has(accessible.role)) {
+ return null;
+ }
+
+ const state = {};
+ accessible.getState(state, {});
+ // We only expect in interactive accessible object to be focusable if it is
+ // not disabled.
+ if (state.value & Ci.nsIAccessibleStates.STATE_UNAVAILABLE) {
+ return null;
+ }
+
+ if (isKeyboardFocusable(accessible)) {
+ return null;
+ }
+
+ const ariaRoles = getAriaRoles(accessible);
+ if (
+ ariaRoles &&
+ (ariaRoles.includes("combobox") || ariaRoles.includes("listbox"))
+ ) {
+ // Do not force ARIA combobox or listbox to be focusable.
+ return null;
+ }
+
+ return { score: FAIL, issue: INTERACTIVE_NOT_FOCUSABLE };
+}
+
+/**
+ * A rule that determines if a focusable accessible has an associated
+ * interactive role.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for having an interactive role if it is
+ * focusable.
+ *
+ * @return {null|Object}
+ * Null if accessible is not interactive or if it is and it has an
+ * interactive role, audit report object otherwise.
+ */
+function semanticsRule(accessible) {
+ if (
+ INTERACTIVE_ROLES.has(accessible.role) ||
+ // Visible listboxes will have focusable state when inside comboboxes.
+ accessible.role === Ci.nsIAccessibleRole.ROLE_COMBOBOX_LIST
+ ) {
+ return null;
+ }
+
+ if (isKeyboardFocusable(accessible)) {
+ if (INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) {
+ return null;
+ }
+
+ // ROLE_TABLE is used for grids too which are considered interactive.
+ if (accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE) {
+ const ariaRoles = getAriaRoles(accessible);
+ if (ariaRoles && ariaRoles.includes("grid")) {
+ return null;
+ }
+ }
+
+ return { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS };
+ }
+
+ const state = {};
+ accessible.getState(state, {});
+ if (
+ // Ignore text leafs.
+ accessible.role === Ci.nsIAccessibleRole.ROLE_TEXT_LEAF ||
+ // Ignore accessibles with no accessible actions.
+ accessible.actionCount === 0 ||
+ // Ignore labels that have a label for relation with their target because
+ // they are clickable.
+ (accessible.role === Ci.nsIAccessibleRole.ROLE_LABEL &&
+ accessible.getRelationByType(Ci.nsIAccessibleRelation.RELATION_LABEL_FOR)
+ .targetsCount > 0) ||
+ // Ignore images that are inside an anchor (have linked state).
+ (accessible.role === Ci.nsIAccessibleRole.ROLE_GRAPHIC &&
+ state.value & Ci.nsIAccessibleStates.STATE_LINKED)
+ ) {
+ return null;
+ }
+
+ // Ignore anything but a click action in the list of actions.
+ for (let i = 0; i < accessible.actionCount; i++) {
+ if (accessible.getActionName(i) === CLICK_ACTION) {
+ return { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * A rule that determines if an element associated with a focusable accessible
+ * has a positive tabindex.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible to be checked for having an element with positive tabindex
+ * attribute.
+ *
+ * @return {null|Object}
+ * Null if accessible is not focusable or if it is and its element's
+ * tabindex attribute is less than 1, audit report object otherwise.
+ */
+function tabIndexRule(accessible) {
+ const { DOMNode } = accessible;
+ if (isInvalidNode(DOMNode)) {
+ return null;
+ }
+
+ if (!isKeyboardFocusable(accessible)) {
+ return null;
+ }
+
+ if (DOMNode.tabIndex > 0) {
+ return { score: WARNING, issue: FOCUSABLE_POSITIVE_TABINDEX };
+ }
+
+ return null;
+}
+
+function auditKeyboard(accessible) {
+ if (isDefunct(accessible)) {
+ return null;
+ }
+ // Do not test anything on accessible objects for documents or frames.
+ if (
+ accessible.role === Ci.nsIAccessibleRole.ROLE_DOCUMENT ||
+ accessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME
+ ) {
+ return null;
+ }
+
+ // Check if interactive accessible can be used by the assistive
+ // technology.
+ let issue = interactiveRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if interactive accessible is also focusable when enabled.
+ issue = focusableRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if accessible object has an element with a positive tabindex.
+ issue = tabIndexRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if a focusable accessible has interactive semantics.
+ issue = semanticsRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ // Check if focusable accessible has associated focus styling.
+ issue = focusStyleRule(accessible);
+ if (issue) {
+ return issue;
+ }
+
+ return issue;
+}
+
+module.exports.auditKeyboard = auditKeyboard;
diff --git a/devtools/server/actors/accessibility/audit/moz.build b/devtools/server/actors/accessibility/audit/moz.build
new file mode 100644
index 0000000000..01bd0af849
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/moz.build
@@ -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/.
+
+DevToolsModules(
+ "contrast.js",
+ "keyboard.js",
+ "text-label.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Accessibility Tools")
diff --git a/devtools/server/actors/accessibility/audit/text-label.js b/devtools/server/actors/accessibility/audit/text-label.js
new file mode 100644
index 0000000000..8570c5cce8
--- /dev/null
+++ b/devtools/server/actors/accessibility/audit/text-label.js
@@ -0,0 +1,438 @@
+/* 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 {
+ accessibility: {
+ AUDIT_TYPE: { TEXT_LABEL },
+ ISSUE_TYPE,
+ SCORES: { BEST_PRACTICES, FAIL, WARNING },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+const {
+ AREA_NO_NAME_FROM_ALT,
+ DIALOG_NO_NAME,
+ DOCUMENT_NO_TITLE,
+ EMBED_NO_NAME,
+ FIGURE_NO_NAME,
+ FORM_FIELDSET_NO_NAME,
+ FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ FORM_NO_NAME,
+ FORM_NO_VISIBLE_NAME,
+ FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ FRAME_NO_NAME,
+ HEADING_NO_CONTENT,
+ HEADING_NO_NAME,
+ IFRAME_NO_NAME_FROM_TITLE,
+ IMAGE_NO_NAME,
+ INTERACTIVE_NO_NAME,
+ MATHML_GLYPH_NO_NAME,
+ TOOLBAR_NO_NAME,
+} = ISSUE_TYPE[TEXT_LABEL];
+
+/**
+ * Check if the accessible is visible to the assistive technology.
+ * @param {nsIAccessible} accessible
+ * Accessible object to be tested for visibility.
+ *
+ * @returns {Boolean}
+ * True if accessible object is visible to assistive technology.
+ */
+function isVisible(accessible) {
+ const state = {};
+ accessible.getState(state, {});
+ return !(state.value & Ci.nsIAccessibleStates.STATE_INVISIBLE);
+}
+
+/**
+ * Get related accessible objects that are targets of labelled by relation e.g.
+ * labels.
+ * @param {nsIAccessible} accessible
+ * Accessible objects to get labels for.
+ *
+ * @returns {Array}
+ * A list of accessible objects that are labels for a given accessible.
+ */
+function getLabels(accessible) {
+ const relation = accessible.getRelationByType(
+ Ci.nsIAccessibleRelation.RELATION_LABELLED_BY
+ );
+ return [...relation.getTargets().enumerate(Ci.nsIAccessible)];
+}
+
+/**
+ * Get a trimmed name of the accessible object.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible objects to get a name for.
+ *
+ * @returns {null|String}
+ * Trimmed name of the accessible object if available.
+ */
+function getAccessibleName(accessible) {
+ return accessible.name && accessible.name.trim();
+}
+
+/**
+ * A text label rule for accessible objects that must have a non empty
+ * accessible name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if accessible object has no or empty name, null
+ * otherwise.
+ */
+const mustHaveNonEmptyNameRule = function (issue, accessible) {
+ const name = getAccessibleName(accessible);
+ return name ? null : { score: FAIL, issue };
+};
+
+/**
+ * A text label rule for accessible objects that should have a non empty
+ * accessible name as a best practice.
+ *
+ * @returns {null|Object}
+ * Best practices audit report if accessible object has no or empty
+ * name, null otherwise.
+ */
+const shouldHaveNonEmptyNameRule = function (issue, accessible) {
+ const name = getAccessibleName(accessible);
+ return name ? null : { score: BEST_PRACTICES, issue };
+};
+
+/**
+ * A text label rule for accessible objects that can be activated via user
+ * action and must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if interactive accessible object has no or
+ * empty name, null otherwise.
+ */
+const interactiveRule = mustHaveNonEmptyNameRule.bind(
+ null,
+ INTERACTIVE_NO_NAME
+);
+
+/**
+ * A text label rule for accessible objects that correspond to dialogs and thus
+ * should have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Best practices audit report if dialog accessible object has no or
+ * empty name, null otherwise.
+ */
+const dialogRule = shouldHaveNonEmptyNameRule.bind(null, DIALOG_NO_NAME);
+
+/**
+ * A text label rule for accessible objects that provide visual information
+ * (images, canvas, etc.) and must have a defined name (that can be empty, e.g.
+ * "").
+ *
+ * @returns {null|Object}
+ * Failure audit report if interactive accessible object has no name,
+ * null otherwise.
+ */
+const imageRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ return name != null ? null : { score: FAIL, issue: IMAGE_NO_NAME };
+};
+
+/**
+ * A text label rule for accessible objects that correspond to form elements.
+ * These objects must have a non-empty name and must have a visible label.
+ *
+ * @returns {null|Object}
+ * Failure audit report if form element accessible object has no name,
+ * warning if the name does not come from a visible label, null
+ * otherwise.
+ */
+const formRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (!name) {
+ return { score: FAIL, issue: FORM_NO_NAME };
+ }
+
+ const labels = getLabels(accessible);
+ const hasNameFromVisibleLabel = labels.some(label => isVisible(label));
+
+ return hasNameFromVisibleLabel
+ ? null
+ : { score: WARNING, issue: FORM_NO_VISIBLE_NAME };
+};
+
+/**
+ * A text label rule for elements that map to ROLE_GROUPING:
+ * * <OPTGROUP> must have a non-empty name and must be provided via the
+ * "label" attribute.
+ * * <FIELDSET> must have a non-empty name and must be provided via the
+ * corresponding <LEGEND> element.
+ *
+ * @returns {null|Object}
+ * Failure audit report if form grouping accessible object has no name,
+ * or has a name that is not derived from a required location, null
+ * otherwise.
+ */
+const formGroupingRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ const { DOMNode } = accessible;
+
+ switch (DOMNode.nodeName) {
+ case "OPTGROUP":
+ return name && DOMNode.label && DOMNode.label.trim() === name
+ ? null
+ : {
+ score: FAIL,
+ issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ };
+ case "FIELDSET":
+ if (!name) {
+ return { score: FAIL, issue: FORM_FIELDSET_NO_NAME };
+ }
+
+ const labels = getLabels(accessible);
+ const hasNameFromLegend = labels.some(
+ label =>
+ label.DOMNode.nodeName === "LEGEND" &&
+ label.name &&
+ label.name.trim() === name &&
+ isVisible(label)
+ );
+
+ return hasNameFromLegend
+ ? null
+ : {
+ score: WARNING,
+ issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ };
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for elements that map to ROLE_TEXT_CONTAINER:
+ * * <METER> mapps to ROLE_TEXT_CONTAINER and must have a name provided via
+ * the visible label. Note: Will only work when bug 559770 is resolved (right
+ * now, unlabelled meters are not mapped to an accessible object).
+ *
+ * @returns {null|Object}
+ * Failure audit report depending on requirements for dialogs or form
+ * meter element, null otherwise.
+ */
+const textContainerRule = function (accessible) {
+ const { DOMNode } = accessible;
+
+ switch (DOMNode.nodeName) {
+ case "DIALOG":
+ return dialogRule(accessible);
+ case "METER":
+ return formRule(accessible);
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for elements that map to ROLE_INTERNAL_FRAME:
+ * * <OBJECT> maps to ROLE_INTERNAL_FRAME. Check the type attribute and whether
+ * it includes "image/" (e.g. image/jpeg, image/png, image/gif). If so, audit
+ * it the same way other image roles are audited.
+ * * <EMBED> maps to ROLE_INTERNAL_FRAME and must have a non-empty name.
+ * * <FRAME> and <IFRAME> map to ROLE_INTERNAL_FRAME and must have a non-empty
+ * title attribute.
+ *
+ * @returns {null|Object}
+ * Failure audit report if the internal frame accessible object name is
+ * not provided or if it is not derived from a required location, null
+ * otherwise.
+ */
+const internalFrameRule = function (accessible) {
+ const { DOMNode } = accessible;
+ switch (DOMNode.nodeName) {
+ case "FRAME":
+ return mustHaveNonEmptyNameRule(FRAME_NO_NAME, accessible);
+ case "IFRAME":
+ const name = getAccessibleName(accessible);
+ const title = DOMNode.title && DOMNode.title.trim();
+
+ return title && title === name
+ ? null
+ : { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE };
+ case "OBJECT": {
+ const type = DOMNode.getAttribute("type");
+ if (!type || !type.startsWith("image/")) {
+ return null;
+ }
+
+ return imageRule(accessible);
+ }
+ case "EMBED": {
+ const type = DOMNode.getAttribute("type");
+ if (!type || !type.startsWith("image/")) {
+ return mustHaveNonEmptyNameRule(EMBED_NO_NAME, accessible);
+ }
+ return imageRule(accessible);
+ }
+ default:
+ return null;
+ }
+};
+
+/**
+ * A text label rule for accessible objects that represent documents and should
+ * have title element provided.
+ *
+ * @returns {null|Object}
+ * Failure audit report if document accessible object has no or empty
+ * title, null otherwise.
+ */
+const documentRule = function (accessible) {
+ const title = accessible.DOMNode.title && accessible.DOMNode.title.trim();
+ return title ? null : { score: FAIL, issue: DOCUMENT_NO_TITLE };
+};
+
+/**
+ * A text label rule for accessible objects that correspond to headings and thus
+ * must be non-empty.
+ *
+ * @returns {null|Object}
+ * Failure audit report if heading accessible object has no or
+ * empty name or if its text content is empty, null otherwise.
+ */
+const headingRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (!name) {
+ return { score: FAIL, issue: HEADING_NO_NAME };
+ }
+
+ const content =
+ accessible.DOMNode.textContent && accessible.DOMNode.textContent.trim();
+ return content ? null : { score: WARNING, issue: HEADING_NO_CONTENT };
+};
+
+/**
+ * A text label rule for accessible objects that represent toolbars and must
+ * have a non-empty name if there is more than one toolbar present.
+ *
+ * @returns {null|Object}
+ * Failure audit report if toolbar accessible object is not the only
+ * toolbar in the document and has no or empty title, null otherwise.
+ */
+const toolbarRule = function (accessible) {
+ const toolbars =
+ accessible.DOMNode.ownerDocument.querySelectorAll(`[role="toolbar"]`);
+
+ return toolbars.length > 1
+ ? mustHaveNonEmptyNameRule(TOOLBAR_NO_NAME, accessible)
+ : null;
+};
+
+/**
+ * A text label rule for accessible objects that represent link (anchors, areas)
+ * and must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if link accessible object has no or empty name,
+ * or in case when it's an <area> element with href attribute the name
+ * is not specified by an alt attribute, null otherwise.
+ */
+const linkRule = function (accessible) {
+ const { DOMNode } = accessible;
+ if (DOMNode.nodeName === "AREA" && DOMNode.hasAttribute("href")) {
+ const alt = DOMNode.getAttribute("alt");
+ const name = getAccessibleName(accessible);
+ return alt && alt.trim() === name
+ ? null
+ : { score: FAIL, issue: AREA_NO_NAME_FROM_ALT };
+ }
+
+ return interactiveRule(accessible);
+};
+
+/**
+ * A text label rule for accessible objects that are used to display
+ * non-standard symbols where existing Unicode characters are not available and
+ * must have a non-empty name.
+ *
+ * @returns {null|Object}
+ * Failure audit report if mglyph accessible object has no or empty
+ * name, and no or empty alt attribute, null otherwise.
+ */
+const mathmlGlyphRule = function (accessible) {
+ const name = getAccessibleName(accessible);
+ if (name) {
+ return null;
+ }
+
+ const { DOMNode } = accessible;
+ const alt = DOMNode.getAttribute("alt");
+ return alt && alt.trim()
+ ? null
+ : { score: FAIL, issue: MATHML_GLYPH_NO_NAME };
+};
+
+const RULES = {
+ [Ci.nsIAccessibleRole.ROLE_BUTTONMENU]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_CANVAS]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECKBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_COLUMNHEADER]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_COMBOBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_DIAGRAM]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_DIALOG]: dialogRule,
+ [Ci.nsIAccessibleRole.ROLE_DOCUMENT]: documentRule,
+ [Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_ENTRY]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_FIGURE]: shouldHaveNonEmptyNameRule.bind(
+ null,
+ FIGURE_NO_NAME
+ ),
+ [Ci.nsIAccessibleRole.ROLE_GRAPHIC]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_GROUPING]: formGroupingRule,
+ [Ci.nsIAccessibleRole.ROLE_HEADING]: headingRule,
+ [Ci.nsIAccessibleRole.ROLE_IMAGE_MAP]: imageRule,
+ [Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME]: internalFrameRule,
+ [Ci.nsIAccessibleRole.ROLE_LINK]: linkRule,
+ [Ci.nsIAccessibleRole.ROLE_LISTBOX]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH]: mathmlGlyphRule,
+ [Ci.nsIAccessibleRole.ROLE_MENUITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_OPTION]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_OUTLINEITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_PAGETAB]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_PROGRESSBAR]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_PUSHBUTTON]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_RADIOBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_ROWHEADER]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_SLIDER]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_SPINBUTTON]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_SWITCH]: formRule,
+ [Ci.nsIAccessibleRole.ROLE_TEXT_CONTAINER]: textContainerRule,
+ [Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON]: interactiveRule,
+ [Ci.nsIAccessibleRole.ROLE_TOOLBAR]: toolbarRule,
+};
+
+/**
+ * Perform audit for WCAG 1.1 criteria related to providing alternative text
+ * depending on the type of content.
+ * @param {nsIAccessible} accessible
+ * Accessible object to be tested to determine if it requires and has
+ * an appropriate text alternative.
+ *
+ * @return {null|Object}
+ * Null if accessible does not need or has the right text alternative,
+ * audit data otherwise. This data is used in the accessibility panel
+ * for its audit filters, audit badges, sidebar checks section and
+ * highlighter.
+ */
+function auditTextLabel(accessible) {
+ const rule = RULES[accessible.role];
+ return rule ? rule(accessible) : null;
+}
+
+module.exports.auditTextLabel = auditTextLabel;
diff --git a/devtools/server/actors/accessibility/constants.js b/devtools/server/actors/accessibility/constants.js
new file mode 100644
index 0000000000..6035b5c844
--- /dev/null
+++ b/devtools/server/actors/accessibility/constants.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 {
+ accessibility: {
+ SIMULATION_TYPE: {
+ ACHROMATOPSIA,
+ DEUTERANOPIA,
+ PROTANOPIA,
+ TRITANOPIA,
+ CONTRAST_LOSS,
+ },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+/**
+ * Constants used in accessibility actors.
+ */
+
+// Color blindness matrix values taken from Machado et al. (2009), https://doi.org/10.1109/TVCG.2009.113:
+// https://www.inf.ufrgs.br/~oliveira/pubs_files/CVD_Simulation/CVD_Simulation.html
+// Contrast loss matrix values are for 50% contrast (see https://docs.rainmeter.net/tips/colormatrix-guide/,
+// and https://stackoverflow.com/questions/23865511/contrast-with-color-matrix). The matrices are flattened
+// 4x5 matrices, needed for docShell setColorMatrix method. i.e. A 4x5 matrix of the form:
+// 1 2 3 4 5
+// 6 7 8 9 10
+// 11 12 13 14 15
+// 16 17 18 19 20
+// will be need to be set in docShell as:
+// [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20]
+const COLOR_TRANSFORMATION_MATRICES = {
+ NONE: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0],
+ [ACHROMATOPSIA]: [
+ 0.299, 0.299, 0.299, 0, 0.587, 0.587, 0.587, 0, 0.114, 0.114, 0.114, 0, 0,
+ 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [PROTANOPIA]: [
+ 0.152286, 0.114503, -0.003882, 0, 1.052583, 0.786281, -0.048116, 0,
+ -0.204868, 0.099216, 1.051998, 0, 0, 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [DEUTERANOPIA]: [
+ 0.367322, 0.280085, -0.01182, 0, 0.860646, 0.672501, 0.04294, 0, -0.227968,
+ 0.047413, 0.968881, 0, 0, 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [TRITANOPIA]: [
+ 1.255528, -0.078411, 0.004733, 0, -0.076749, 0.930809, 0.691367, 0,
+ -0.178779, 0.147602, 0.3039, 0, 0, 0, 0, 1, 0, 0, 0, 0,
+ ],
+ [CONTRAST_LOSS]: [
+ 0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5, 0, 0, 0, 0, 0.5, 0.25, 0.25, 0.25, 0,
+ ],
+};
+
+exports.simulation = {
+ COLOR_TRANSFORMATION_MATRICES,
+};
diff --git a/devtools/server/actors/accessibility/moz.build b/devtools/server/actors/accessibility/moz.build
new file mode 100644
index 0000000000..4da1cd0b24
--- /dev/null
+++ b/devtools/server/actors/accessibility/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 += [
+ "audit",
+]
+
+DevToolsModules(
+ "accessibility.js",
+ "accessible.js",
+ "constants.js",
+ "parent-accessibility.js",
+ "simulator.js",
+ "walker.js",
+ "worker.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Accessibility Tools")
diff --git a/devtools/server/actors/accessibility/parent-accessibility.js b/devtools/server/actors/accessibility/parent-accessibility.js
new file mode 100644
index 0000000000..fd2c945ea7
--- /dev/null
+++ b/devtools/server/actors/accessibility/parent-accessibility.js
@@ -0,0 +1,154 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ parentAccessibilitySpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+const PREF_ACCESSIBILITY_FORCE_DISABLED = "accessibility.force_disabled";
+
+class ParentAccessibilityActor extends Actor {
+ constructor(conn) {
+ super(conn, parentAccessibilitySpec);
+
+ this.userPref = Services.prefs.getIntPref(
+ PREF_ACCESSIBILITY_FORCE_DISABLED
+ );
+
+ if (this.enabled && !this.accService) {
+ // Set a local reference to an accessibility service if accessibility was
+ // started elsewhere to ensure that parent process a11y service does not
+ // get GC'ed away.
+ this.accService = Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ }
+
+ Services.obs.addObserver(this, "a11y-consumers-changed");
+ Services.prefs.addObserver(PREF_ACCESSIBILITY_FORCE_DISABLED, this);
+ }
+
+ bootstrap() {
+ return {
+ canBeDisabled: this.canBeDisabled,
+ canBeEnabled: this.canBeEnabled,
+ };
+ }
+
+ observe(subject, topic, data) {
+ if (topic === "a11y-consumers-changed") {
+ // This event is fired when accessibility service consumers change. Since
+ // this observer lives in parent process there are 2 possible consumers of
+ // a11y service: XPCOM and PlatformAPI (e.g. screen readers). We only care
+ // about PlatformAPI consumer changes because when set, we can no longer
+ // disable accessibility service.
+ const { PlatformAPI } = JSON.parse(data);
+ this.emit("can-be-disabled-change", !PlatformAPI);
+ } else if (
+ !this.disabling &&
+ topic === "nsPref:changed" &&
+ data === PREF_ACCESSIBILITY_FORCE_DISABLED
+ ) {
+ // PREF_ACCESSIBILITY_FORCE_DISABLED preference change event. When set to
+ // >=1, it means that the user wants to disable accessibility service and
+ // prevent it from starting in the future. Note: we also check
+ // this.disabling state when handling this pref change because this is how
+ // we disable the accessibility inspector itself.
+ this.emit("can-be-enabled-change", this.canBeEnabled);
+ }
+ }
+
+ /**
+ * A getter that indicates if accessibility service is enabled.
+ *
+ * @return {Boolean}
+ * True if accessibility service is on.
+ */
+ get enabled() {
+ return Services.appinfo.accessibilityEnabled;
+ }
+
+ /**
+ * A getter that indicates if the accessibility service can be disabled.
+ *
+ * @return {Boolean}
+ * True if accessibility service can be disabled.
+ */
+ get canBeDisabled() {
+ if (this.enabled) {
+ const a11yService = Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ const { PlatformAPI } = JSON.parse(a11yService.getConsumers());
+ return !PlatformAPI;
+ }
+
+ return true;
+ }
+
+ /**
+ * A getter that indicates if the accessibility service can be enabled.
+ *
+ * @return {Boolean}
+ * True if accessibility service can be enabled.
+ */
+ get canBeEnabled() {
+ return Services.prefs.getIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED) < 1;
+ }
+
+ /**
+ * Enable accessibility service (via XPCOM service).
+ */
+ enable() {
+ if (this.enabled || !this.canBeEnabled) {
+ return;
+ }
+
+ this.accService = Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ }
+
+ /**
+ * Force disable accessibility service. This method removes the reference to
+ * the XPCOM a11y service object and flips the
+ * PREF_ACCESSIBILITY_FORCE_DISABLED preference on and off to shutdown a11y
+ * service.
+ */
+ disable() {
+ if (!this.enabled || !this.canBeDisabled) {
+ return;
+ }
+
+ this.disabling = true;
+ this.accService = null;
+ // Set PREF_ACCESSIBILITY_FORCE_DISABLED to 1 to force disable
+ // accessibility service. This is the only way to guarantee an immediate
+ // accessibility service shutdown in all processes. This also prevents
+ // accessibility service from starting up in the future.
+ Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, 1);
+ // Set PREF_ACCESSIBILITY_FORCE_DISABLED back to previous default or user
+ // set value. This will not start accessibility service until the user
+ // activates it again. It simply ensures that accessibility service can
+ // start again (when value is below 1).
+ Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, this.userPref);
+ delete this.disabling;
+ }
+
+ /**
+ * Destroy the helper class, remove all listeners and if possible disable
+ * accessibility service in the parent process.
+ */
+ destroy() {
+ this.disable();
+ super.destroy();
+ Services.obs.removeObserver(this, "a11y-consumers-changed");
+ Services.prefs.removeObserver(PREF_ACCESSIBILITY_FORCE_DISABLED, this);
+ }
+}
+
+exports.ParentAccessibilityActor = ParentAccessibilityActor;
diff --git a/devtools/server/actors/accessibility/simulator.js b/devtools/server/actors/accessibility/simulator.js
new file mode 100644
index 0000000000..4f7e059d8c
--- /dev/null
+++ b/devtools/server/actors/accessibility/simulator.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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ simulatorSpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+const {
+ simulation: { COLOR_TRANSFORMATION_MATRICES },
+} = require("resource://devtools/server/actors/accessibility/constants.js");
+
+/**
+ * The SimulatorActor is responsible for setting color matrices
+ * based on the simulation type specified.
+ */
+class SimulatorActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, simulatorSpec);
+ this.targetActor = targetActor;
+ }
+
+ /**
+ * Simulates a type of visual impairment (i.e. color blindness or contrast loss).
+ *
+ * @param {Object} options
+ * Properties: {Array} types
+ * Contains the types of visual impairment(s) to be simulated.
+ * Set default color matrix if array is empty.
+ * @return {Boolean}
+ * True if matrix was successfully applied, false otherwise.
+ */
+ simulate(options) {
+ if (options.types.length > 1) {
+ return false;
+ }
+
+ return this.setColorMatrix(
+ COLOR_TRANSFORMATION_MATRICES[
+ options.types.length === 1 ? options.types[0] : "NONE"
+ ]
+ );
+ }
+
+ setColorMatrix(colorMatrix) {
+ if (!this.docShell) {
+ return false;
+ }
+
+ try {
+ this.docShell.setColorMatrix(colorMatrix);
+ } catch (error) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Disables all simulations by setting the default color matrix.
+ */
+ disable() {
+ this.simulate({ types: [] });
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.disable();
+ this.targetActor = null;
+ }
+
+ get docShell() {
+ return this.targetActor && this.targetActor.docShell;
+ }
+}
+
+exports.SimulatorActor = SimulatorActor;
diff --git a/devtools/server/actors/accessibility/walker.js b/devtools/server/actors/accessibility/walker.js
new file mode 100644
index 0000000000..17a21af482
--- /dev/null
+++ b/devtools/server/actors/accessibility/walker.js
@@ -0,0 +1,1315 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ accessibleWalkerSpec,
+} = require("resource://devtools/shared/specs/accessibility.js");
+
+const {
+ simulation: { COLOR_TRANSFORMATION_MATRICES },
+} = require("resource://devtools/server/actors/accessibility/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "AccessibleActor",
+ "resource://devtools/server/actors/accessibility/accessible.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CustomHighlighterActor"],
+ "resource://devtools/server/actors/highlighters.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "events",
+ "resource://devtools/shared/event-emitter.js"
+);
+loader.lazyRequireGetter(
+ this,
+ ["isWindowIncluded", "isFrameWithChildTarget"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isXUL",
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ [
+ "isDefunct",
+ "loadSheetForBackgroundCalculation",
+ "removeSheetForBackgroundCalculation",
+ ],
+ "resource://devtools/server/actors/utils/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "accessibility",
+ "resource://devtools/shared/constants.js",
+ true
+);
+
+const kStateHover = 0x00000004; // ElementState::HOVER
+
+const {
+ EVENT_TEXT_CHANGED,
+ EVENT_TEXT_INSERTED,
+ EVENT_TEXT_REMOVED,
+ EVENT_ACCELERATOR_CHANGE,
+ EVENT_ACTION_CHANGE,
+ EVENT_DEFACTION_CHANGE,
+ EVENT_DESCRIPTION_CHANGE,
+ EVENT_DOCUMENT_ATTRIBUTES_CHANGED,
+ EVENT_HIDE,
+ EVENT_NAME_CHANGE,
+ EVENT_OBJECT_ATTRIBUTE_CHANGED,
+ EVENT_REORDER,
+ EVENT_STATE_CHANGE,
+ EVENT_TEXT_ATTRIBUTE_CHANGED,
+ EVENT_VALUE_CHANGE,
+} = Ci.nsIAccessibleEvent;
+
+// TODO: We do not need this once bug 1422913 is fixed. We also would not need
+// to fire a name change event for an accessible that has an updated subtree and
+// that has its name calculated from the said subtree.
+const NAME_FROM_SUBTREE_RULE_ROLES = new Set([
+ Ci.nsIAccessibleRole.ROLE_BUTTONDROPDOWN,
+ Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
+ Ci.nsIAccessibleRole.ROLE_CELL,
+ Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
+ Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
+ Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
+ Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
+ Ci.nsIAccessibleRole.ROLE_DEFINITION,
+ Ci.nsIAccessibleRole.ROLE_GRID_CELL,
+ Ci.nsIAccessibleRole.ROLE_HEADING,
+ Ci.nsIAccessibleRole.ROLE_KEY,
+ Ci.nsIAccessibleRole.ROLE_LABEL,
+ Ci.nsIAccessibleRole.ROLE_LINK,
+ Ci.nsIAccessibleRole.ROLE_LISTITEM,
+ Ci.nsIAccessibleRole.ROLE_MATHML_IDENTIFIER,
+ Ci.nsIAccessibleRole.ROLE_MATHML_NUMBER,
+ Ci.nsIAccessibleRole.ROLE_MATHML_OPERATOR,
+ Ci.nsIAccessibleRole.ROLE_MATHML_TEXT,
+ Ci.nsIAccessibleRole.ROLE_MATHML_STRING_LITERAL,
+ Ci.nsIAccessibleRole.ROLE_MATHML_GLYPH,
+ Ci.nsIAccessibleRole.ROLE_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_OPTION,
+ Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
+ Ci.nsIAccessibleRole.ROLE_PAGETAB,
+ Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
+ Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
+ Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
+ Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
+ Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
+ Ci.nsIAccessibleRole.ROLE_ROW,
+ Ci.nsIAccessibleRole.ROLE_ROWHEADER,
+ Ci.nsIAccessibleRole.ROLE_SUMMARY,
+ Ci.nsIAccessibleRole.ROLE_SWITCH,
+ Ci.nsIAccessibleRole.ROLE_TERM,
+ Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
+ Ci.nsIAccessibleRole.ROLE_TOOLTIP,
+]);
+
+const IS_OSX = Services.appinfo.OS === "Darwin";
+
+const {
+ SCORES: { BEST_PRACTICES, FAIL, WARNING },
+} = accessibility;
+
+/**
+ * Helper function that determines if nsIAccessible object is in stale state. When an
+ * object is stale it means its subtree is not up to date.
+ *
+ * @param {nsIAccessible} accessible
+ * object to be tested.
+ * @return {Boolean}
+ * True if accessible object is stale, false otherwise.
+ */
+function isStale(accessible) {
+ const extraState = {};
+ accessible.getState({}, extraState);
+ // extraState.value is a bitmask. We are applying bitwise AND to mask out
+ // irrelevant states.
+ return !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_STALE);
+}
+
+/**
+ * Get accessibility audit starting with the passed accessible object as a root.
+ *
+ * @param {Object} acc
+ * AccessibileActor to be used as the root for the audit.
+ * @param {Object} options
+ * Options for running audit, may include:
+ * - types: Array of audit types to be performed during audit.
+ * @param {Map} report
+ * An accumulator map to be used to store audit information.
+ * @param {Object} progress
+ * An audit project object that is used to track the progress of the
+ * audit and send progress "audit-event" events to the client.
+ */
+function getAudit(acc, options, report, progress) {
+ if (acc.isDefunct) {
+ return;
+ }
+
+ // Audit returns a promise, save the actual value in the report.
+ report.set(
+ acc,
+ acc.audit(options).then(result => {
+ report.set(acc, result);
+ progress.increment();
+ })
+ );
+
+ for (const child of acc.children()) {
+ getAudit(child, options, report, progress);
+ }
+}
+
+/**
+ * A helper class that is used to track audit progress and send progress events
+ * to the client.
+ */
+class AuditProgress {
+ constructor(walker) {
+ this.completed = 0;
+ this.percentage = 0;
+ this.walker = walker;
+ }
+
+ setTotal(size) {
+ this.size = size;
+ }
+
+ notify() {
+ this.walker.emit("audit-event", {
+ type: "progress",
+ progress: {
+ total: this.size,
+ percentage: this.percentage,
+ completed: this.completed,
+ },
+ });
+ }
+
+ increment() {
+ this.completed++;
+ const { completed, size } = this;
+ if (!size) {
+ return;
+ }
+
+ const percentage = Math.round((completed / size) * 100);
+ if (percentage > this.percentage) {
+ this.percentage = percentage;
+ this.notify();
+ }
+ }
+
+ destroy() {
+ this.walker = null;
+ }
+}
+
+/**
+ * The AccessibleWalkerActor stores a cache of AccessibleActors that represent
+ * accessible objects in a given document.
+ *
+ * It is also responsible for implicitely initializing and shutting down
+ * accessibility engine by storing a reference to the XPCOM accessibility
+ * service.
+ */
+class AccessibleWalkerActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, accessibleWalkerSpec);
+ this.targetActor = targetActor;
+ this.refMap = new Map();
+ this._loadedSheets = new WeakMap();
+ this.setA11yServiceGetter();
+ this.onPick = this.onPick.bind(this);
+ this.onHovered = this.onHovered.bind(this);
+ this._preventContentEvent = this._preventContentEvent.bind(this);
+ this.onKey = this.onKey.bind(this);
+ this.onFocusIn = this.onFocusIn.bind(this);
+ this.onFocusOut = this.onFocusOut.bind(this);
+ this.onHighlighterEvent = this.onHighlighterEvent.bind(this);
+ }
+
+ get highlighter() {
+ if (!this._highlighter) {
+ this._highlighter = new CustomHighlighterActor(
+ this,
+ "AccessibleHighlighter"
+ );
+
+ this.manage(this._highlighter);
+ this._highlighter.on("highlighter-event", this.onHighlighterEvent);
+ }
+
+ return this._highlighter;
+ }
+
+ get tabbingOrderHighlighter() {
+ if (!this._tabbingOrderHighlighter) {
+ this._tabbingOrderHighlighter = new CustomHighlighterActor(
+ this,
+ "TabbingOrderHighlighter"
+ );
+
+ this.manage(this._tabbingOrderHighlighter);
+ }
+
+ return this._tabbingOrderHighlighter;
+ }
+
+ setA11yServiceGetter() {
+ DevToolsUtils.defineLazyGetter(this, "a11yService", () => {
+ Services.obs.addObserver(this, "accessible-event");
+ return Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ });
+ }
+
+ get rootWin() {
+ return this.targetActor && this.targetActor.window;
+ }
+
+ get rootDoc() {
+ return this.targetActor && this.targetActor.window.document;
+ }
+
+ get isXUL() {
+ return isXUL(this.rootWin);
+ }
+
+ get colorMatrix() {
+ if (!this.targetActor.docShell) {
+ return null;
+ }
+
+ const colorMatrix = this.targetActor.docShell.getColorMatrix();
+ if (
+ colorMatrix.length === 0 ||
+ colorMatrix === COLOR_TRANSFORMATION_MATRICES.NONE
+ ) {
+ return null;
+ }
+
+ return colorMatrix;
+ }
+
+ reset() {
+ try {
+ Services.obs.removeObserver(this, "accessible-event");
+ } catch (e) {
+ // Accessible event observer might not have been initialized if a11y
+ // service was never used.
+ }
+
+ this.cancelPick();
+
+ // Clean up accessible actors cache.
+ this.clearRefs();
+
+ this._childrenPromise = null;
+ delete this.a11yService;
+ this.setA11yServiceGetter();
+ }
+
+ /**
+ * Remove existing cache (of accessible actors) from tree.
+ */
+ clearRefs() {
+ for (const actor of this.refMap.values()) {
+ actor.destroy();
+ }
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.reset();
+
+ if (this._highlighter) {
+ this._highlighter.off("highlighter-event", this.onHighlighterEvent);
+ this._highlighter = null;
+ }
+
+ if (this._tabbingOrderHighlighter) {
+ this._tabbingOrderHighlighter = null;
+ }
+
+ this.targetActor = null;
+ this.refMap = null;
+ }
+
+ getRef(rawAccessible) {
+ return this.refMap.get(rawAccessible);
+ }
+
+ addRef(rawAccessible) {
+ let actor = this.refMap.get(rawAccessible);
+ if (actor) {
+ return actor;
+ }
+
+ actor = new AccessibleActor(this, rawAccessible);
+ // Add the accessible actor as a child of this accessible walker actor,
+ // assigning it an actorID.
+ this.manage(actor);
+ this.refMap.set(rawAccessible, actor);
+
+ return actor;
+ }
+
+ /**
+ * Clean up accessible actors cache for a given accessible's subtree.
+ *
+ * @param {null|nsIAccessible} rawAccessible
+ */
+ purgeSubtree(rawAccessible) {
+ if (!rawAccessible) {
+ return;
+ }
+
+ try {
+ for (
+ let child = rawAccessible.firstChild;
+ child;
+ child = child.nextSibling
+ ) {
+ this.purgeSubtree(child);
+ }
+ } catch (e) {
+ // rawAccessible or its descendants are defunct.
+ }
+
+ const actor = this.getRef(rawAccessible);
+ if (actor) {
+ actor.destroy();
+ }
+ }
+
+ unmanage(actor) {
+ if (actor instanceof AccessibleActor) {
+ this.refMap.delete(actor.rawAccessible);
+ }
+ Actor.prototype.unmanage.call(this, actor);
+ }
+
+ /**
+ * A helper method. Accessibility walker is assumed to have only 1 child which
+ * is the top level document.
+ */
+ async children() {
+ if (this._childrenPromise) {
+ return this._childrenPromise;
+ }
+
+ this._childrenPromise = Promise.all([this.getDocument()]);
+ const children = await this._childrenPromise;
+ this._childrenPromise = null;
+ return children;
+ }
+
+ /**
+ * A promise for a root document accessible actor that only resolves when its
+ * corresponding document accessible object is fully loaded.
+ *
+ * @return {Promise}
+ */
+ getDocument() {
+ if (!this.rootDoc || !this.rootDoc.documentElement) {
+ return this.once("document-ready").then(docAcc => this.addRef(docAcc));
+ }
+
+ if (this.isXUL) {
+ const doc = this.addRef(this.getRawAccessibleFor(this.rootDoc));
+ return Promise.resolve(doc);
+ }
+
+ const doc = this.getRawAccessibleFor(this.rootDoc);
+
+ // For non-visible same-process iframes we don't get a document and
+ // won't get a "document-ready" event.
+ if (!doc && !this.rootWin.windowGlobalChild.isProcessRoot) {
+ // We can ignore such document as there won't be anything to audit in them.
+ return null;
+ }
+
+ if (!doc || isStale(doc)) {
+ return this.once("document-ready").then(docAcc => this.addRef(docAcc));
+ }
+
+ return Promise.resolve(this.addRef(doc));
+ }
+
+ /**
+ * Get an accessible actor for a domnode actor.
+ * @param {Object} domNode
+ * domnode actor for which accessible actor is being created.
+ * @return {Promse}
+ * A promise that resolves when accessible actor is created for a
+ * domnode actor.
+ */
+ getAccessibleFor(domNode) {
+ // We need to make sure that the document is loaded processed by a11y first.
+ return this.getDocument().then(() => {
+ const rawAccessible = this.getRawAccessibleFor(domNode.rawNode);
+ // Not all DOM nodes have corresponding accessible objects. It's usually
+ // the case where there is no semantics or relevance to the accessibility
+ // client.
+ if (!rawAccessible) {
+ return null;
+ }
+
+ return this.addRef(rawAccessible);
+ });
+ }
+
+ /**
+ * Get a raw accessible object for a raw node.
+ * @param {DOMNode} rawNode
+ * Raw node for which accessible object is being retrieved.
+ * @return {nsIAccessible}
+ * Accessible object for a given DOMNode.
+ */
+ getRawAccessibleFor(rawNode) {
+ // Accessible can only be retrieved iff accessibility service is enabled.
+ if (!Services.appinfo.accessibilityEnabled) {
+ return null;
+ }
+
+ return this.a11yService.getAccessibleFor(rawNode);
+ }
+
+ async getAncestry(accessible) {
+ if (!accessible || accessible.indexInParent === -1) {
+ return [];
+ }
+ const doc = await this.getDocument();
+ if (!doc) {
+ return [];
+ }
+
+ const ancestry = [];
+ if (accessible === doc) {
+ return ancestry;
+ }
+
+ try {
+ let parent = accessible;
+ while (parent && (parent = parent.parentAcc) && parent != doc) {
+ ancestry.push(parent);
+ }
+ ancestry.push(doc);
+ } catch (error) {
+ throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
+ }
+
+ return ancestry.map(parent => ({
+ accessible: parent,
+ children: parent.children(),
+ }));
+ }
+
+ /**
+ * Run accessibility audit and return relevant ancestries for AccessibleActors
+ * that have non-empty audit checks.
+ *
+ * @param {Object} options
+ * Options for running audit, may include:
+ * - types: Array of audit types to be performed during audit.
+ *
+ * @return {Promise}
+ * A promise that resolves when the audit is complete and all relevant
+ * ancestries are calculated.
+ */
+ async audit(options) {
+ const doc = await this.getDocument();
+ if (!doc) {
+ return [];
+ }
+
+ const report = new Map();
+ this._auditProgress = new AuditProgress(this);
+ getAudit(doc, options, report, this._auditProgress);
+ this._auditProgress.setTotal(report.size);
+ await Promise.all(report.values());
+
+ const ancestries = [];
+ for (const [acc, audit] of report.entries()) {
+ // Filter out audits that have no failing checks.
+ if (
+ audit &&
+ Object.values(audit).some(
+ check =>
+ check != null &&
+ !check.error &&
+ [BEST_PRACTICES, FAIL, WARNING].includes(check.score)
+ )
+ ) {
+ ancestries.push(this.getAncestry(acc));
+ }
+ }
+
+ return Promise.all(ancestries);
+ }
+
+ /**
+ * Start accessibility audit. The result of this function will not be an audit
+ * report. Instead, an "audit-event" event will be fired when the audit is
+ * completed or fails.
+ *
+ * @param {Object} options
+ * Options for running audit, may include:
+ * - types: Array of audit types to be performed during audit.
+ */
+ startAudit(options) {
+ // Audit is already running, wait for the "audit-event" event.
+ if (this._auditing) {
+ return;
+ }
+
+ this._auditing = this.audit(options)
+ // We do not want to block on audit request, instead fire "audit-event"
+ // event when internal audit is finished or failed.
+ .then(ancestries =>
+ this.emit("audit-event", {
+ type: "completed",
+ ancestries,
+ })
+ )
+ .catch(() => this.emit("audit-event", { type: "error" }))
+ .finally(() => {
+ this._auditing = null;
+ if (this._auditProgress) {
+ this._auditProgress.destroy();
+ this._auditProgress = null;
+ }
+ });
+ }
+
+ onHighlighterEvent(data) {
+ this.emit("highlighter-event", data);
+ }
+
+ /**
+ * Accessible event observer function.
+ *
+ * @param {Ci.nsIAccessibleEvent} subject
+ * accessible event object.
+ */
+ // eslint-disable-next-line complexity
+ observe(subject) {
+ const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
+ const rawAccessible = event.accessible;
+ const accessible = this.getRef(rawAccessible);
+
+ if (rawAccessible instanceof Ci.nsIAccessibleDocument && !accessible) {
+ const rootDocAcc = this.getRawAccessibleFor(this.rootDoc);
+ if (rawAccessible === rootDocAcc && !isStale(rawAccessible)) {
+ this.clearRefs();
+ // If it's a top level document notify listeners about the document
+ // being ready.
+ events.emit(this, "document-ready", rawAccessible);
+ }
+ }
+
+ switch (event.eventType) {
+ case EVENT_STATE_CHANGE:
+ const { state, isEnabled } = event.QueryInterface(
+ Ci.nsIAccessibleStateChangeEvent
+ );
+ const isBusy = state & Ci.nsIAccessibleStates.STATE_BUSY;
+ if (accessible) {
+ // Only propagate state change events for active accessibles.
+ if (isBusy && isEnabled) {
+ if (rawAccessible instanceof Ci.nsIAccessibleDocument) {
+ // Remove existing cache from tree.
+ this.clearRefs();
+ }
+ return;
+ }
+ events.emit(accessible, "states-change", accessible.states);
+ }
+
+ break;
+ case EVENT_NAME_CHANGE:
+ if (accessible) {
+ events.emit(
+ accessible,
+ "name-change",
+ rawAccessible.name,
+ event.DOMNode == this.rootDoc
+ ? undefined
+ : this.getRef(rawAccessible.parent)
+ );
+ }
+ break;
+ case EVENT_VALUE_CHANGE:
+ if (accessible) {
+ events.emit(accessible, "value-change", rawAccessible.value);
+ }
+ break;
+ case EVENT_DESCRIPTION_CHANGE:
+ if (accessible) {
+ events.emit(
+ accessible,
+ "description-change",
+ rawAccessible.description
+ );
+ }
+ break;
+ case EVENT_REORDER:
+ if (accessible) {
+ accessible
+ .children()
+ .forEach(child =>
+ events.emit(child, "index-in-parent-change", child.indexInParent)
+ );
+ events.emit(accessible, "reorder", rawAccessible.childCount);
+ }
+ break;
+ case EVENT_HIDE:
+ if (event.DOMNode == this.rootDoc) {
+ this.clearRefs();
+ } else {
+ this.purgeSubtree(rawAccessible);
+ }
+ break;
+ case EVENT_DEFACTION_CHANGE:
+ case EVENT_ACTION_CHANGE:
+ if (accessible) {
+ events.emit(accessible, "actions-change", accessible.actions);
+ }
+ break;
+ case EVENT_TEXT_CHANGED:
+ case EVENT_TEXT_INSERTED:
+ case EVENT_TEXT_REMOVED:
+ if (accessible) {
+ events.emit(accessible, "text-change");
+ if (NAME_FROM_SUBTREE_RULE_ROLES.has(rawAccessible.role)) {
+ events.emit(
+ accessible,
+ "name-change",
+ rawAccessible.name,
+ event.DOMNode == this.rootDoc
+ ? undefined
+ : this.getRef(rawAccessible.parent)
+ );
+ }
+ }
+ break;
+ case EVENT_DOCUMENT_ATTRIBUTES_CHANGED:
+ case EVENT_OBJECT_ATTRIBUTE_CHANGED:
+ case EVENT_TEXT_ATTRIBUTE_CHANGED:
+ if (accessible) {
+ events.emit(accessible, "attributes-change", accessible.attributes);
+ }
+ break;
+ // EVENT_ACCELERATOR_CHANGE is currently not fired by gecko accessibility.
+ case EVENT_ACCELERATOR_CHANGE:
+ if (accessible) {
+ events.emit(
+ accessible,
+ "shortcut-change",
+ accessible.keyboardShortcut
+ );
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Ensure that nothing interferes with the audit for an accessible object
+ * (CSS, overlays) by load accessibility highlighter style sheet used for
+ * preventing transitions and applying transparency when calculating colour
+ * contrast as well as temporarily hiding accessible highlighter overlay.
+ * @param {Object} win
+ * Window where highlighting happens.
+ */
+ async clearStyles(win) {
+ const requests = this._loadedSheets.get(win);
+ if (requests != null) {
+ this._loadedSheets.set(win, requests + 1);
+ return;
+ }
+
+ // Disable potential mouse driven transitions (This is important because accessibility
+ // highlighter temporarily modifies text color related CSS properties. In case where
+ // there are transitions that affect them, there might be unexpected side effects when
+ // taking a snapshot for contrast measurement).
+ loadSheetForBackgroundCalculation(win);
+ this._loadedSheets.set(win, 1);
+ await this.hideHighlighter();
+ }
+
+ /**
+ * Restore CSS and overlays that could've interfered with the audit for an
+ * accessible object by unloading accessibility highlighter style sheet used
+ * for preventing transitions and applying transparency when calculating
+ * colour contrast and potentially restoring accessible highlighter overlay.
+ * @param {Object} win
+ * Window where highlighting was happenning.
+ */
+ async restoreStyles(win) {
+ const requests = this._loadedSheets.get(win);
+ if (!requests) {
+ return;
+ }
+
+ if (requests > 1) {
+ this._loadedSheets.set(win, requests - 1);
+ return;
+ }
+
+ await this.showHighlighter();
+ removeSheetForBackgroundCalculation(win);
+ this._loadedSheets.delete(win);
+ }
+
+ async hideHighlighter() {
+ // TODO: Fix this workaround that temporarily removes higlighter bounds
+ // overlay that can interfere with the contrast ratio calculation.
+ if (this._highlighter) {
+ const highlighter = this._highlighter.instance;
+ await highlighter.isReady;
+ highlighter.hideAccessibleBounds();
+ }
+ }
+
+ async showHighlighter() {
+ // TODO: Fix this workaround that temporarily removes higlighter bounds
+ // overlay that can interfere with the contrast ratio calculation.
+ if (this._highlighter) {
+ const highlighter = this._highlighter.instance;
+ await highlighter.isReady;
+ highlighter.showAccessibleBounds();
+ }
+ }
+
+ /**
+ * Public method used to show an accessible object highlighter on the client
+ * side.
+ *
+ * @param {Object} accessible
+ * AccessibleActor to be highlighted.
+ * @param {Object} options
+ * Object used for passing options. Available options:
+ * - duration {Number}
+ * Duration of time that the highlighter should be shown.
+ * @return {Boolean}
+ * True if highlighter shows the accessible object.
+ */
+ async highlightAccessible(accessible, options = {}) {
+ this.unhighlight();
+ // Do not highlight if accessible is dead.
+ if (!accessible || accessible.isDefunct || accessible.indexInParent < 0) {
+ return false;
+ }
+
+ this._highlightingAccessible = accessible;
+ const { bounds } = accessible;
+ if (!bounds) {
+ return false;
+ }
+
+ const { DOMNode: rawNode } = accessible.rawAccessible;
+ const audit = await accessible.audit();
+ if (this._highlightingAccessible !== accessible) {
+ return false;
+ }
+
+ const { name, role } = accessible;
+ const { highlighter } = this;
+ await highlighter.instance.isReady;
+ if (this._highlightingAccessible !== accessible) {
+ return false;
+ }
+
+ const shown = highlighter.show(
+ { rawNode },
+ { ...options, ...bounds, name, role, audit, isXUL: this.isXUL }
+ );
+ this._highlightingAccessible = null;
+
+ return shown;
+ }
+
+ /**
+ * Public method used to hide an accessible object highlighter on the client
+ * side.
+ */
+ unhighlight() {
+ if (!this._highlighter) {
+ return;
+ }
+
+ this.highlighter.hide();
+ this._highlightingAccessible = null;
+ }
+
+ /**
+ * Picking state that indicates if picking is currently enabled and, if so,
+ * what the current and hovered accessible objects are.
+ */
+ _isPicking = false;
+ _currentAccessible = null;
+
+ /**
+ * Check is event handling is allowed.
+ */
+ _isEventAllowed({ view }) {
+ return this.rootWin.isChromeWindow || isWindowIncluded(this.rootWin, view);
+ }
+
+ /**
+ * Check if the DOM event received when picking shold be ignored.
+ * @param {Event} event
+ */
+ _ignoreEventWhenPicking(event) {
+ return (
+ !this._isPicking ||
+ // If the DOM event is about a remote frame, only the WalkerActor for that
+ // remote frame target should emit RDP events (hovered/picked/...). And
+ // all other WalkerActor for intermediate iframe and top level document
+ // targets should stay silent.
+ isFrameWithChildTarget(
+ this.targetActor,
+ event.originalTarget || event.target
+ )
+ );
+ }
+
+ _preventContentEvent(event) {
+ if (this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ const target = event.originalTarget || event.target;
+ if (target !== this._currentTarget) {
+ this._resetStateAndReleaseTarget();
+ this._currentTarget = target;
+ // We use InspectorUtils to save the original hover content state of the target
+ // element (that includes its hover state). In order to not trigger any visual
+ // changes to the element that depend on its hover state we remove the state while
+ // the element is the most current target of the highlighter.
+ //
+ // TODO: This logic can be removed if/when we can use elementsAtPoint API for
+ // determining topmost DOMNode that corresponds to specific coordinates. We would
+ // then be able to use a highlighter overlay that would prevent all pointer events
+ // to content but still render highlighter for the node/element correctly.
+ this._currentTargetHoverState =
+ InspectorUtils.getContentState(target) & kStateHover;
+ InspectorUtils.removeContentState(target, kStateHover);
+ }
+ }
+
+ /**
+ * Click event handler for when picking is enabled.
+ *
+ * @param {Object} event
+ * Current click event.
+ */
+ onPick(event) {
+ if (this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ // If shift is pressed, this is only a preview click, send the event to
+ // the client, but don't stop picking.
+ if (event.shiftKey) {
+ if (!this._currentAccessible) {
+ this._currentAccessible = this._findAndAttachAccessible(event);
+ }
+ events.emit(this, "picker-accessible-previewed", this._currentAccessible);
+ return;
+ }
+
+ this._unsetPickerEnvironment();
+ this._isPicking = false;
+ if (!this._currentAccessible) {
+ this._currentAccessible = this._findAndAttachAccessible(event);
+ }
+ events.emit(this, "picker-accessible-picked", this._currentAccessible);
+ }
+
+ /**
+ * Hover event handler for when picking is enabled.
+ *
+ * @param {Object} event
+ * Current hover event.
+ */
+ async onHovered(event) {
+ if (this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ const accessible = this._findAndAttachAccessible(event);
+ if (!accessible || this._currentAccessible === accessible) {
+ return;
+ }
+
+ this._currentAccessible = accessible;
+ // Highlight current accessible and by the time we are done, if accessible that was
+ // highlighted is not current any more (user moved the mouse to a new node) highlight
+ // the most current accessible again.
+ const shown = await this.highlightAccessible(accessible);
+ if (this._isPicking && shown && accessible === this._currentAccessible) {
+ events.emit(this, "picker-accessible-hovered", accessible);
+ }
+ }
+
+ /**
+ * Keyboard event handler for when picking is enabled.
+ *
+ * @param {Object} event
+ * Current keyboard event.
+ */
+ onKey(event) {
+ if (!this._currentAccessible || this._ignoreEventWhenPicking(event)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ /**
+ * KEY: Action/scope
+ * ENTER/CARRIAGE_RETURN: Picks current accessible
+ * ESC/CTRL+SHIFT+C: Cancels picker
+ */
+ switch (event.keyCode) {
+ // Select the element.
+ case event.DOM_VK_RETURN:
+ this.onPick(event);
+ break;
+ // Cancel pick mode.
+ case event.DOM_VK_ESCAPE:
+ this.cancelPick();
+ events.emit(this, "picker-accessible-canceled");
+ break;
+ case event.DOM_VK_C:
+ if (
+ (IS_OSX && event.metaKey && event.altKey) ||
+ (!IS_OSX && event.ctrlKey && event.shiftKey)
+ ) {
+ this.cancelPick();
+ events.emit(this, "picker-accessible-canceled");
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Picker method that starts picker content listeners.
+ */
+ pick() {
+ if (!this._isPicking) {
+ this._isPicking = true;
+ this._setPickerEnvironment();
+ }
+ }
+
+ /**
+ * This pick method also focuses the highlighter's target window.
+ */
+ pickAndFocus() {
+ this.pick();
+ this.rootWin.focus();
+ }
+
+ attachAccessible(rawAccessible, accessibleDocument) {
+ // If raw accessible object is defunct or detached, no need to cache it and
+ // its ancestry.
+ if (
+ !rawAccessible ||
+ isDefunct(rawAccessible) ||
+ rawAccessible.indexInParent < 0
+ ) {
+ return null;
+ }
+
+ const accessible = this.addRef(rawAccessible);
+ // There is a chance that ancestry lookup can fail if the accessible is in
+ // the detached subtree. At that point the root accessible object would be
+ // defunct and accessing it via parent property will throw.
+ try {
+ let parent = accessible;
+ while (parent && parent.rawAccessible != accessibleDocument) {
+ parent = parent.parentAcc;
+ }
+ } catch (error) {
+ throw new Error(`Failed to get ancestor for ${accessible}: ${error}`);
+ }
+
+ return accessible;
+ }
+
+ /**
+ * Find deepest accessible object that corresponds to the screen coordinates of the
+ * mouse pointer and attach it to the AccessibilityWalker tree.
+ *
+ * @param {Object} event
+ * Correspoinding content event.
+ * @return {null|Object}
+ * Accessible object, if available, that corresponds to a DOM node.
+ */
+ _findAndAttachAccessible(event) {
+ const target = event.originalTarget || event.target;
+ const win = target.ownerGlobal;
+ // This event might be inside a sub-document, so don't use this.rootDoc.
+ const docAcc = this.getRawAccessibleFor(win.document);
+ // If the target is inside a pop-up widget, we need to query the pop-up
+ // Accessible, not the DocAccessible. The DocAccessible can't hit test
+ // inside pop-ups.
+ const popup = win.isChromeWindow ? target.closest("panel") : null;
+ const containerAcc = popup ? this.getRawAccessibleFor(popup) : docAcc;
+ const { devicePixelRatio } = this.rootWin;
+ const rawAccessible = containerAcc.getDeepestChildAtPointInProcess(
+ event.screenX * devicePixelRatio,
+ event.screenY * devicePixelRatio
+ );
+ return this.attachAccessible(rawAccessible, docAcc);
+ }
+
+ /**
+ * Start picker content listeners.
+ */
+ _setPickerEnvironment() {
+ const target = this.targetActor.chromeEventHandler;
+ target.addEventListener("mousemove", this.onHovered, true);
+ target.addEventListener("click", this.onPick, true);
+ target.addEventListener("mousedown", this._preventContentEvent, true);
+ target.addEventListener("mouseup", this._preventContentEvent, true);
+ target.addEventListener("mouseover", this._preventContentEvent, true);
+ target.addEventListener("mouseout", this._preventContentEvent, true);
+ target.addEventListener("mouseleave", this._preventContentEvent, true);
+ target.addEventListener("mouseenter", this._preventContentEvent, true);
+ target.addEventListener("dblclick", this._preventContentEvent, true);
+ target.addEventListener("keydown", this.onKey, true);
+ target.addEventListener("keyup", this._preventContentEvent, true);
+ }
+
+ /**
+ * If content is still alive, stop picker content listeners, reset the hover state for
+ * last target element.
+ */
+ _unsetPickerEnvironment() {
+ const target = this.targetActor.chromeEventHandler;
+
+ if (!target) {
+ return;
+ }
+
+ target.removeEventListener("mousemove", this.onHovered, true);
+ target.removeEventListener("click", this.onPick, true);
+ target.removeEventListener("mousedown", this._preventContentEvent, true);
+ target.removeEventListener("mouseup", this._preventContentEvent, true);
+ target.removeEventListener("mouseover", this._preventContentEvent, true);
+ target.removeEventListener("mouseout", this._preventContentEvent, true);
+ target.removeEventListener("mouseleave", this._preventContentEvent, true);
+ target.removeEventListener("mouseenter", this._preventContentEvent, true);
+ target.removeEventListener("dblclick", this._preventContentEvent, true);
+ target.removeEventListener("keydown", this.onKey, true);
+ target.removeEventListener("keyup", this._preventContentEvent, true);
+
+ this._resetStateAndReleaseTarget();
+ }
+
+ /**
+ * When using accessibility highlighter, we keep track of the most current event pointer
+ * event target. In order to update or release the target, we need to make sure we set
+ * the content state (using InspectorUtils) to its original value.
+ *
+ * TODO: This logic can be removed if/when we can use elementsAtPoint API for
+ * determining topmost DOMNode that corresponds to specific coordinates. We would then
+ * be able to use a highlighter overlay that would prevent all pointer events to content
+ * but still render highlighter for the node/element correctly.
+ */
+ _resetStateAndReleaseTarget() {
+ if (!this._currentTarget) {
+ return;
+ }
+
+ try {
+ if (this._currentTargetHoverState) {
+ InspectorUtils.setContentState(this._currentTarget, kStateHover);
+ }
+ } catch (e) {
+ // DOMNode is already dead.
+ }
+
+ this._currentTarget = null;
+ this._currentTargetState = null;
+ }
+
+ /**
+ * Cacncel picker pick. Remvoe all content listeners and hide the highlighter.
+ */
+ cancelPick() {
+ this.unhighlight();
+
+ if (this._isPicking) {
+ this._unsetPickerEnvironment();
+ this._isPicking = false;
+ this._currentAccessible = null;
+ }
+ }
+
+ /**
+ * Indicates that the tabbing order current active element (focused) is being
+ * tracked.
+ */
+ _isTrackingTabbingOrderFocus = false;
+
+ /**
+ * Current focused element in the tabbing order.
+ */
+ _currentFocusedTabbingOrder = null;
+
+ /**
+ * Focusin event handler for when interacting with tabbing order overlay.
+ *
+ * @param {Object} event
+ * Most recent focusin event.
+ */
+ async onFocusIn(event) {
+ if (!this._isTrackingTabbingOrderFocus) {
+ return;
+ }
+
+ const target = event.originalTarget || event.target;
+ if (target === this._currentFocusedTabbingOrder) {
+ return;
+ }
+
+ this._currentFocusedTabbingOrder = target;
+ this.tabbingOrderHighlighter._highlighter.updateFocus({
+ node: target,
+ focused: true,
+ });
+ }
+
+ /**
+ * Focusout event handler for when interacting with tabbing order overlay.
+ *
+ * @param {Object} event
+ * Most recent focusout event.
+ */
+ async onFocusOut(event) {
+ if (
+ !this._isTrackingTabbingOrderFocus ||
+ !this._currentFocusedTabbingOrder
+ ) {
+ return;
+ }
+
+ const target = event.originalTarget || event.target;
+ // Sanity check.
+ if (target !== this._currentFocusedTabbingOrder) {
+ console.warn(
+ `focusout target: ${target} does not match current focused element in tabbing order: ${this._currentFocusedTabbingOrder}`
+ );
+ }
+
+ this.tabbingOrderHighlighter._highlighter.updateFocus({
+ node: this._currentFocusedTabbingOrder,
+ focused: false,
+ });
+ this._currentFocusedTabbingOrder = null;
+ }
+
+ /**
+ * Show tabbing order overlay for a given target.
+ *
+ * @param {Object} elm
+ * domnode actor to be used as the starting point for generating the
+ * tabbing order.
+ * @param {Number} index
+ * Starting index for the tabbing order.
+ *
+ * @return {JSON}
+ * Tabbing order information for the last element in the tabbing
+ * order. It includes a ContentDOMReference for the node and a tabbing
+ * index. If we are at the end of the tabbing order for the top level
+ * content document, the ContentDOMReference will be null. If focus
+ * manager discovered a remote IFRAME, then the ContentDOMReference
+ * references the IFRAME itself.
+ */
+ showTabbingOrder(elm, index) {
+ // Start track focus related events (only once). `showTabbingOrder` will be
+ // called multiple times for a given target if it contains other remote
+ // targets.
+ if (!this._isTrackingTabbingOrderFocus) {
+ this._isTrackingTabbingOrderFocus = true;
+ const target = this.targetActor.chromeEventHandler;
+ target.addEventListener("focusin", this.onFocusIn, true);
+ target.addEventListener("focusout", this.onFocusOut, true);
+ }
+
+ return this.tabbingOrderHighlighter.show(elm, { index });
+ }
+
+ /**
+ * Hide tabbing order overlay for a given target.
+ */
+ hideTabbingOrder() {
+ if (!this._tabbingOrderHighlighter) {
+ return;
+ }
+
+ this.tabbingOrderHighlighter.hide();
+ if (!this._isTrackingTabbingOrderFocus) {
+ return;
+ }
+
+ this._isTrackingTabbingOrderFocus = false;
+ this._currentFocusedTabbingOrder = null;
+ const target = this.targetActor.chromeEventHandler;
+ if (target) {
+ target.removeEventListener("focusin", this.onFocusIn, true);
+ target.removeEventListener("focusout", this.onFocusOut, true);
+ }
+ }
+}
+
+exports.AccessibleWalkerActor = AccessibleWalkerActor;
diff --git a/devtools/server/actors/accessibility/worker.js b/devtools/server/actors/accessibility/worker.js
new file mode 100644
index 0000000000..75dc78e5b2
--- /dev/null
+++ b/devtools/server/actors/accessibility/worker.js
@@ -0,0 +1,103 @@
+/* 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";
+
+/**
+ * Import `createTask` to communicate with `devtools/shared/worker`.
+ */
+importScripts("resource://gre/modules/workers/require.js");
+const { createTask } = require("resource://devtools/shared/worker/helper.js");
+
+/**
+ * @see LineGraphWidget.prototype.setDataFromTimestamps in Graphs.js
+ * @param number id
+ * @param array timestamps
+ * @param number interval
+ * @param number duration
+ */
+createTask(self, "getBgRGBA", ({ dataTextBuf, dataBackgroundBuf }) =>
+ getBgRGBA(dataTextBuf, dataBackgroundBuf)
+);
+
+/**
+ * Calculates the luminance of a rgba tuple based on the formula given in
+ * https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ *
+ * @param {Array} rgba An array with [r,g,b,a] values.
+ * @return {Number} The calculated luminance.
+ */
+function calculateLuminance(rgba) {
+ for (let i = 0; i < 3; i++) {
+ rgba[i] /= 255;
+ rgba[i] =
+ rgba[i] < 0.03928
+ ? rgba[i] / 12.92
+ : Math.pow((rgba[i] + 0.055) / 1.055, 2.4);
+ }
+ return 0.2126 * rgba[0] + 0.7152 * rgba[1] + 0.0722 * rgba[2];
+}
+
+/**
+ * Get RGBA or a range of RGBAs for the background pixels under the text. If luminance is
+ * uniform, only return one value of RGBA, otherwise return values that correspond to the
+ * min and max luminances.
+ * @param {ImageData} dataTextBuf
+ * pixel data for the accessible object with text visible.
+ * @param {ImageData} dataBackgroundBuf
+ * pixel data for the accessible object with transparent text.
+ * @return {Object}
+ * RGBA or a range of RGBAs with min and max values.
+ */
+function getBgRGBA(dataTextBuf, dataBackgroundBuf) {
+ let min = [0, 0, 0, 1];
+ let max = [255, 255, 255, 1];
+ let minLuminance = 1;
+ let maxLuminance = 0;
+ const luminances = {};
+ const dataText = new Uint8ClampedArray(dataTextBuf);
+ const dataBackground = new Uint8ClampedArray(dataBackgroundBuf);
+
+ let foundDistinctColor = false;
+ for (let i = 0; i < dataText.length; i = i + 4) {
+ const tR = dataText[i];
+ const bgR = dataBackground[i];
+ const tG = dataText[i + 1];
+ const bgG = dataBackground[i + 1];
+ const tB = dataText[i + 2];
+ const bgB = dataBackground[i + 2];
+
+ // Ignore pixels that are the same where pixels that are different between the two
+ // images are assumed to belong to the text within the node.
+ if (tR === bgR && tG === bgG && tB === bgB) {
+ continue;
+ }
+
+ foundDistinctColor = true;
+
+ const bgColor = `rgb(${bgR}, ${bgG}, ${bgB})`;
+ let luminance = luminances[bgColor];
+
+ if (!luminance) {
+ // Calculate luminance for the RGB value and store it to only measure once.
+ luminance = calculateLuminance([bgR, bgG, bgB]);
+ luminances[bgColor] = luminance;
+ }
+
+ if (minLuminance >= luminance) {
+ minLuminance = luminance;
+ min = [bgR, bgG, bgB, 1];
+ }
+
+ if (maxLuminance <= luminance) {
+ maxLuminance = luminance;
+ max = [bgR, bgG, bgB, 1];
+ }
+ }
+
+ if (!foundDistinctColor) {
+ return null;
+ }
+
+ return minLuminance === maxLuminance ? { value: max } : { min, max };
+}
diff --git a/devtools/server/actors/addon/addons.js b/devtools/server/actors/addon/addons.js
new file mode 100644
index 0000000000..95a3738d61
--- /dev/null
+++ b/devtools/server/actors/addon/addons.js
@@ -0,0 +1,83 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ addonsSpec,
+} = require("resource://devtools/shared/specs/addon/addons.js");
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs",
+ { loadInDevToolsLoader: false }
+);
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+
+// This actor is used by DevTools as well as external tools such as webext-run
+// and the Firefox VS-Code plugin. see bug #1578108
+class AddonsActor extends Actor {
+ constructor(conn) {
+ super(conn, addonsSpec);
+ }
+
+ async installTemporaryAddon(addonPath, openDevTools) {
+ let addonFile;
+ let addon;
+ try {
+ addonFile = new FileUtils.File(addonPath);
+ addon = await AddonManager.installTemporaryAddon(addonFile);
+ } catch (error) {
+ throw new Error(`Could not install add-on at '${addonPath}': ${error}`);
+ }
+
+ Services.obs.notifyObservers(null, "devtools-installed-addon", addon.id);
+
+ // Try to open DevTools for the installed add-on.
+ // Note that it will only work on Firefox Desktop.
+ // On Android, we don't ship DevTools UI.
+ // about:debugging is only using this API when debugging its own firefox instance,
+ // so for now, there is no chance of calling this on Android.
+ if (openDevTools) {
+ // This module is typically loaded in the loader spawn by DevToolsStartup,
+ // in a distinct compartment thanks to useDistinctSystemPrincipalLoader and loadInDevToolsLoader flag.
+ // But here we want to reuse the shared module loader.
+ // We do not want to load devtools.js in the server's distinct module loader.
+ const loader = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs",
+ { loadInDevToolsLoader: false }
+ );
+ const {
+ gDevTools,
+ // eslint-disable-next-line mozilla/reject-some-requires
+ } = loader.require("resource://devtools/client/framework/devtools.js");
+ gDevTools.showToolboxForWebExtension(addon.id);
+ }
+
+ // TODO: once the add-on actor has been refactored to use
+ // protocol.js, we could return it directly.
+ // return new AddonTargetActor(this.conn, addon);
+
+ // Return a pseudo add-on object that a calling client can work
+ // with. Provide a flag that the client can use to detect when it
+ // gets upgraded to a real actor object.
+ return { id: addon.id, actor: false };
+ }
+
+ async uninstallAddon(addonId) {
+ const addon = await AddonManager.getAddonByID(addonId);
+
+ // We only support uninstallation of temporarily loaded add-ons at the
+ // moment.
+ if (!addon?.temporarilyInstalled) {
+ throw new Error(`Could not uninstall add-on "${addonId}"`);
+ }
+
+ await addon.uninstall();
+ }
+}
+
+exports.AddonsActor = AddonsActor;
diff --git a/devtools/server/actors/addon/moz.build b/devtools/server/actors/addon/moz.build
new file mode 100644
index 0000000000..e382173641
--- /dev/null
+++ b/devtools/server/actors/addon/moz.build
@@ -0,0 +1,10 @@
+# -*- 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(
+ "addons.js",
+ "webextension-inspected-window.js",
+)
diff --git a/devtools/server/actors/addon/webextension-inspected-window.js b/devtools/server/actors/addon/webextension-inspected-window.js
new file mode 100644
index 0000000000..e69a206c9d
--- /dev/null
+++ b/devtools/server/actors/addon/webextension-inspected-window.js
@@ -0,0 +1,680 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ webExtensionInspectedWindowSpec,
+} = require("resource://devtools/shared/specs/addon/webextension-inspected-window.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+loader.lazyGetter(
+ this,
+ "NodeActor",
+ () =>
+ require("resource://devtools/server/actors/inspector/node.js").NodeActor,
+ true
+);
+
+// A weak set of the documents for which a warning message has been
+// already logged (so that we don't keep emitting the same warning if an
+// extension keeps calling the devtools.inspectedWindow.eval API method
+// when it fails to retrieve a result, but we do log the warning message
+// if the user reloads the window):
+//
+// WeakSet<Document>
+const deniedWarningDocuments = new WeakSet();
+
+function isSystemPrincipalWindow(window) {
+ return window.document.nodePrincipal.isSystemPrincipal;
+}
+
+// Create the exceptionInfo property in the format expected by a
+// WebExtension inspectedWindow.eval API calls.
+function createExceptionInfoResult(props) {
+ return {
+ exceptionInfo: {
+ isError: true,
+ code: "E_PROTOCOLERROR",
+ description: "Unknown Inspector protocol error",
+
+ // Apply the passed properties.
+ ...props,
+ },
+ };
+}
+
+// Show a warning message in the webconsole when an extension
+// eval request has been denied, so that the user knows about it
+// even if the extension doesn't report the error itself.
+function logAccessDeniedWarning(window, callerInfo, extensionPolicy) {
+ // Do not log the same warning multiple times for the same document.
+ if (deniedWarningDocuments.has(window.document)) {
+ return;
+ }
+
+ deniedWarningDocuments.add(window.document);
+
+ const { name } = extensionPolicy;
+
+ // System principals have a null nodePrincipal.URI and so we use
+ // the url from window.location.href.
+ const reportedURIorPrincipal = isSystemPrincipalWindow(window)
+ ? Services.io.newURI(window.location.href)
+ : window.document.nodePrincipal;
+
+ const error = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+
+ const msg = `The extension "${name}" is not allowed to access ${reportedURIorPrincipal.spec}`;
+
+ const innerWindowId = window.windowGlobalChild.innerWindowId;
+
+ const errorFlag = 0;
+
+ let { url, lineNumber } = callerInfo;
+
+ const callerURI = callerInfo.url && Services.io.newURI(callerInfo.url);
+
+ // callerInfo.url is not the full path to the file that called the WebExtensions
+ // API yet (Bug 1448878), and so we associate the error to the url of the extension
+ // manifest.json file as a fallback.
+ if (callerURI.filePath === "/") {
+ url = extensionPolicy.getURL("/manifest.json");
+ lineNumber = null;
+ }
+
+ error.initWithWindowID(
+ msg,
+ url,
+ lineNumber,
+ 0,
+ 0,
+ errorFlag,
+ "webExtensions",
+ innerWindowId
+ );
+ Services.console.logMessage(error);
+}
+
+function extensionAllowedToInspectPrincipal(extensionPolicy, principal) {
+ if (principal.isNullPrincipal) {
+ // data: and sandboxed documents.
+ //
+ // Rather than returning true unconditionally, we go through additional
+ // checks to prevent execution in sandboxed documents created by principals
+ // that extensions cannot access otherwise.
+ principal = principal.precursorPrincipal;
+ if (!principal) {
+ // Top-level about:blank, etc.
+ return true;
+ }
+ }
+ if (!principal.isContentPrincipal) {
+ return false;
+ }
+ const principalURI = principal.URI;
+ if (principalURI.schemeIs("https") || principalURI.schemeIs("http")) {
+ if (WebExtensionPolicy.isRestrictedURI(principalURI)) {
+ return false;
+ }
+ if (extensionPolicy.quarantinedFromURI(principalURI)) {
+ return false;
+ }
+ // Common case: http(s) allowed.
+ return true;
+ }
+
+ if (principalURI.schemeIs("moz-extension")) {
+ // Ordinarily, we don't allow extensions to execute arbitrary code in
+ // their own context. The devtools.inspectedWindow.eval API is a special
+ // case - this can only be used through the devtools_page feature, which
+ // requires the user to open the developer tools first. If an extension
+ // really wants to debug itself, we let it do so.
+ return extensionPolicy.id === principal.addonId;
+ }
+
+ if (principalURI.schemeIs("file")) {
+ return true;
+ }
+
+ return false;
+}
+
+class CustomizedReload {
+ constructor(params) {
+ this.docShell = params.targetActor.window.docShell;
+ this.docShell.QueryInterface(Ci.nsIWebProgress);
+
+ this.inspectedWindowEval = params.inspectedWindowEval;
+ this.callerInfo = params.callerInfo;
+
+ this.ignoreCache = params.ignoreCache;
+ this.injectedScript = params.injectedScript;
+
+ this.customizedReloadWindows = new WeakSet();
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]);
+
+ get window() {
+ return this.docShell.DOMWindow;
+ }
+
+ get webNavigation() {
+ return this.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation);
+ }
+
+ get browsingContext() {
+ return this.docShell.browsingContext;
+ }
+
+ start() {
+ if (!this.waitForReloadCompleted) {
+ this.waitForReloadCompleted = new Promise((resolve, reject) => {
+ this.resolveReloadCompleted = resolve;
+ this.rejectReloadCompleted = reject;
+
+ let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+
+ if (this.ignoreCache) {
+ reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+
+ try {
+ if (this.injectedScript) {
+ // Listen to the newly created document elements only if there is an
+ // injectedScript to evaluate.
+ Services.obs.addObserver(this, "initial-document-element-inserted");
+ }
+
+ // Watch the loading progress and clear the current CustomizedReload once the
+ // page has been reloaded (or if its reloading has been interrupted).
+ this.docShell.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+
+ this.webNavigation.reload(reloadFlags);
+ } catch (err) {
+ // Cancel the injected script listener if the reload fails
+ // (which will also report the error by rejecting the promise).
+ this.stop(err);
+ }
+ });
+ }
+
+ return this.waitForReloadCompleted;
+ }
+
+ observe(subject, topic, data) {
+ if (topic !== "initial-document-element-inserted") {
+ return;
+ }
+
+ const document = subject;
+ const window = document?.defaultView;
+
+ // Filter out non interesting documents.
+ if (!document || !document.location || !window) {
+ return;
+ }
+
+ const subjectDocShell = window.docShell;
+
+ // Keep track of the set of window objects where we are going to inject
+ // the injectedScript: the top level window and all its descendant
+ // that are still of type content (filtering out loaded XUL pages, if any).
+ if (window == this.window) {
+ this.customizedReloadWindows.add(window);
+ } else if (subjectDocShell.sameTypeParent) {
+ const parentWindow = subjectDocShell.sameTypeParent.domWindow;
+ if (parentWindow && this.customizedReloadWindows.has(parentWindow)) {
+ this.customizedReloadWindows.add(window);
+ }
+ }
+
+ if (this.customizedReloadWindows.has(window)) {
+ const { apiErrorResult } = this.inspectedWindowEval(
+ this.callerInfo,
+ this.injectedScript,
+ {},
+ window
+ );
+
+ // Log only apiErrorResult, because no one is waiting for the
+ // injectedScript result, and any exception is going to be logged
+ // in the inspectedWindow webconsole.
+ if (apiErrorResult) {
+ console.error(
+ "Unexpected Error in injectedScript during inspectedWindow.reload for",
+ `${this.callerInfo.url}:${this.callerInfo.lineNumber}`,
+ apiErrorResult
+ );
+ }
+ }
+ }
+
+ onStateChange(webProgress, request, state, status) {
+ if (webProgress.DOMWindow !== this.window) {
+ return;
+ }
+
+ if (state & Ci.nsIWebProgressListener.STATE_STOP) {
+ if (status == Cr.NS_BINDING_ABORTED) {
+ // The customized reload has been interrupted and we can clear
+ // the CustomizedReload and reject the promise.
+ const url = this.window.location.href;
+ this.stop(
+ new Error(
+ `devtools.inspectedWindow.reload on ${url} has been interrupted`
+ )
+ );
+ } else {
+ // Once the top level frame has been loaded, we can clear the customized reload
+ // and resolve the promise.
+ this.stop();
+ }
+ }
+ }
+
+ stop(error) {
+ if (this.stopped) {
+ return;
+ }
+
+ this.docShell.removeProgressListener(this);
+
+ if (this.injectedScript) {
+ Services.obs.removeObserver(this, "initial-document-element-inserted");
+ }
+
+ if (error) {
+ this.rejectReloadCompleted(error);
+ } else {
+ this.resolveReloadCompleted();
+ }
+
+ this.stopped = true;
+ }
+}
+
+class WebExtensionInspectedWindowActor extends Actor {
+ /**
+ * Created the WebExtension InspectedWindow actor
+ */
+ constructor(conn, targetActor) {
+ super(conn, webExtensionInspectedWindowSpec);
+ this.targetActor = targetActor;
+ }
+
+ destroy(conn) {
+ super.destroy();
+
+ if (this.customizedReload) {
+ this.customizedReload.stop(
+ new Error("WebExtensionInspectedWindowActor destroyed")
+ );
+ delete this.customizedReload;
+ }
+
+ if (this._dbg) {
+ this._dbg.disable();
+ delete this._dbg;
+ }
+ }
+
+ get dbg() {
+ if (this._dbg) {
+ return this._dbg;
+ }
+
+ this._dbg = this.targetActor.makeDebugger();
+ return this._dbg;
+ }
+
+ get window() {
+ return this.targetActor.window;
+ }
+
+ get webNavigation() {
+ return this.targetActor.webNavigation;
+ }
+
+ createEvalBindings(dbgWindow, options) {
+ const bindings = Object.create(null);
+
+ let selectedDOMNode;
+
+ if (options.toolboxSelectedNodeActorID) {
+ const actor = DevToolsServer.searchAllConnectionsForActor(
+ options.toolboxSelectedNodeActorID
+ );
+ if (actor && actor instanceof NodeActor) {
+ selectedDOMNode = actor.rawNode;
+ }
+ }
+
+ Object.defineProperty(bindings, "$0", {
+ enumerable: true,
+ configurable: true,
+ get: () => {
+ if (selectedDOMNode && !Cu.isDeadWrapper(selectedDOMNode)) {
+ return dbgWindow.makeDebuggeeValue(selectedDOMNode);
+ }
+
+ return undefined;
+ },
+ });
+
+ // This function is used by 'eval' and 'reload' requests, but only 'eval'
+ // passes 'toolboxConsoleActor' from the client side in order to set
+ // the 'inspect' binding.
+ Object.defineProperty(bindings, "inspect", {
+ enumerable: true,
+ configurable: true,
+ value: dbgWindow.makeDebuggeeValue(object => {
+ const consoleActor = DevToolsServer.searchAllConnectionsForActor(
+ options.toolboxConsoleActorID
+ );
+ if (consoleActor) {
+ const dbgObj = consoleActor.makeDebuggeeValue(object);
+ consoleActor.inspectObject(
+ dbgObj,
+ "webextension-devtools-inspectedWindow-eval"
+ );
+ } else {
+ // TODO(rpl): evaluate if it would be better to raise an exception
+ // to the caller code instead.
+ console.error("Toolbox Console RDP Actor not found");
+ }
+ }),
+ });
+
+ return bindings;
+ }
+
+ /**
+ * 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 {webExtensionReloadOptions} options
+ * used to optionally enable the reload customizations.
+ * @param {boolean|undefined} options.ignoreCache
+ * enable/disable the cache bypass headers.
+ * @param {string|undefined} options.userAgent
+ * customize the userAgent during the page reload.
+ * @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.
+ */
+ async reload(callerInfo, { ignoreCache, userAgent, injectedScript }) {
+ if (isSystemPrincipalWindow(this.window)) {
+ console.error(
+ "Ignored inspectedWindow.reload on system principal target for " +
+ `${callerInfo.url}:${callerInfo.lineNumber}`
+ );
+ return {};
+ }
+
+ await new Promise(resolve => {
+ const delayedReload = () => {
+ // This won't work while the browser is shutting down and we don't really
+ // care.
+ if (Services.startup.shuttingDown) {
+ return;
+ }
+
+ if (injectedScript || userAgent) {
+ if (this.customizedReload) {
+ // TODO(rpl): check what chrome does, and evaluate if queue the new reload
+ // after the current one has been completed.
+ console.error(
+ "Reload already in progress. Ignored inspectedWindow.reload for " +
+ `${callerInfo.url}:${callerInfo.lineNumber}`
+ );
+ return;
+ }
+
+ try {
+ this.customizedReload = new CustomizedReload({
+ targetActor: this.targetActor,
+ inspectedWindowEval: this.eval.bind(this),
+ callerInfo,
+ injectedScript,
+ ignoreCache,
+ });
+
+ this.customizedReload
+ .start()
+ .catch(err => {
+ console.error(err);
+ })
+ .then(() => {
+ delete this.customizedReload;
+ resolve();
+ });
+ } catch (err) {
+ // Cancel the customized reload (if any) on exception during the
+ // reload setup.
+ if (this.customizedReload) {
+ this.customizedReload.stop(err);
+ }
+ throw err;
+ }
+ } else {
+ // If there is no custom user agent and/or injected script, then
+ // we can reload the target without subscribing any observer/listener.
+ let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (ignoreCache) {
+ reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ }
+ this.webNavigation.reload(reloadFlags);
+ resolve();
+ }
+ };
+
+ // Execute the reload in a dispatched runnable, so that we can
+ // return the reply to the caller before the reload is actually
+ // started.
+ Services.tm.dispatchToMainThread(delayedReload);
+ });
+
+ return {};
+ }
+
+ /**
+ * Evaluate the provided javascript code in a target window (that is always the
+ * targetActor window when called through RDP protocol, or the passed
+ * customTargetWindow when called directly from the CustomizedReload instances).
+ *
+ * @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 {string} expression
+ * the javascript code to be evaluated in the target window
+ *
+ * @param {webExtensionEvalOptions} evalOptions
+ * used to optionally enable the eval customizations.
+ * NOTE: none of the eval options is currently implemented, they will be already
+ * reported as unsupported by the WebExtensions schema validation wrappers, but
+ * an additional level of error reporting is going to be applied here, so that
+ * if the server and the client have different ideas of which option is supported
+ * the eval call result will contain detailed informations (in the format usually
+ * expected for errors not raised in the evaluated javascript code).
+ *
+ * @param {DOMWindow|undefined} customTargetWindow
+ * Used in the CustomizedReload instances to evaluate the `injectedScript`
+ * javascript code in every sub-frame of the target window during the tab reload.
+ * NOTE: this parameter is not part of the RDP protocol exposed by this actor, when
+ * it is called over the remote debugging protocol the target window is always
+ * `targetActor.window`.
+ */
+ // eslint-disable-next-line complexity
+ eval(callerInfo, expression, options, customTargetWindow) {
+ const window = customTargetWindow || this.window;
+ options = options || {};
+
+ const extensionPolicy = WebExtensionPolicy.getByID(callerInfo.addonId);
+
+ if (!extensionPolicy) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s %s",
+ details: ["Caller extension not found for", callerInfo.url],
+ });
+ }
+
+ if (!window) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [
+ "The target window is not defined. inspectedWindow.eval not executed.",
+ ],
+ });
+ }
+
+ if (
+ !extensionAllowedToInspectPrincipal(
+ extensionPolicy,
+ window.document.nodePrincipal
+ )
+ ) {
+ // Log the error for the user to know that the extension request has been
+ // denied (the extension may not warn the user at all).
+ logAccessDeniedWarning(window, callerInfo, extensionPolicy);
+
+ // The error message is generic here. If access is disallowed, we do not
+ // expose the URL either.
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [
+ "This extension is not allowed on the current inspected window origin",
+ ],
+ });
+ }
+
+ // Raise an error on the unsupported options.
+ if (
+ options.frameURL ||
+ options.contextSecurityOrigin ||
+ options.useContentScriptContext
+ ) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [
+ "The inspectedWindow.eval options are currently not supported",
+ ],
+ });
+ }
+
+ const dbgWindow = this.dbg.makeGlobalObjectReference(window);
+
+ let evalCalledFrom = callerInfo.url;
+ if (callerInfo.lineNumber) {
+ evalCalledFrom += `:${callerInfo.lineNumber}`;
+ }
+
+ const bindings = this.createEvalBindings(dbgWindow, options);
+
+ const result = dbgWindow.executeInGlobalWithBindings(expression, bindings, {
+ url: `debugger eval called from ${evalCalledFrom} - eval code`,
+ });
+
+ let evalResult;
+
+ if (result) {
+ if ("return" in result) {
+ evalResult = result.return;
+ } else if ("yield" in result) {
+ evalResult = result.yield;
+ } else if ("throw" in result) {
+ const throwErr = result.throw;
+
+ // XXXworkers: Calling unsafeDereference() returns an object with no
+ // toString method in workers. See Bug 1215120.
+ const unsafeDereference =
+ throwErr &&
+ typeof throwErr === "object" &&
+ throwErr.unsafeDereference();
+ const message = unsafeDereference?.toString
+ ? unsafeDereference.toString()
+ : String(throwErr);
+ const stack = unsafeDereference?.stack ? unsafeDereference.stack : null;
+
+ return {
+ exceptionInfo: {
+ isException: true,
+ value: `${message}\n\t${stack}`,
+ },
+ };
+ }
+ } else {
+ // TODO(rpl): can the result of executeInGlobalWithBinding be null or
+ // undefined? (which means that it is not a return, a yield or a throw).
+ console.error(
+ "Unexpected empty inspectedWindow.eval result for",
+ `${callerInfo.url}:${callerInfo.lineNumber}`
+ );
+ }
+
+ if (evalResult) {
+ try {
+ // Return the evalResult as a grip (used by the WebExtensions
+ // devtools inspector's sidebar.setExpression API method).
+ if (options.evalResultAsGrip) {
+ if (!options.toolboxConsoleActorID) {
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s - %s",
+ details: [
+ "Unexpected invalid sidebar panel expression request",
+ "missing toolboxConsoleActorID",
+ ],
+ });
+ }
+
+ const consoleActor = DevToolsServer.searchAllConnectionsForActor(
+ options.toolboxConsoleActorID
+ );
+
+ return { valueGrip: consoleActor.createValueGrip(evalResult) };
+ }
+
+ if (evalResult && typeof evalResult === "object") {
+ evalResult = evalResult.unsafeDereference();
+ }
+ evalResult = JSON.parse(JSON.stringify(evalResult));
+ } catch (err) {
+ // The evaluation result cannot be sent over the RDP Protocol,
+ // report it as with the same data format used in the corresponding
+ // chrome API method.
+ return createExceptionInfoResult({
+ description: "Inspector protocol error: %s",
+ details: [String(err)],
+ });
+ }
+ }
+
+ return { value: evalResult };
+ }
+}
+
+exports.WebExtensionInspectedWindowActor = WebExtensionInspectedWindowActor;
diff --git a/devtools/server/actors/animation-type-longhand.js b/devtools/server/actors/animation-type-longhand.js
new file mode 100644
index 0000000000..febf8457ad
--- /dev/null
+++ b/devtools/server/actors/animation-type-longhand.js
@@ -0,0 +1,442 @@
+/* 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";
+
+// Types of animation types of longhand properties.
+exports.ANIMATION_TYPE_FOR_LONGHANDS = [
+ [
+ "discrete",
+ new Set([
+ "align-content",
+ "align-items",
+ "align-self",
+ "align-tracks",
+ "aspect-ratio",
+ "appearance",
+ "backface-visibility",
+ "background-attachment",
+ "background-blend-mode",
+ "background-clip",
+ "background-image",
+ "background-origin",
+ "background-repeat",
+ "baseline-source",
+ "border-bottom-style",
+ "border-collapse",
+ "border-image-repeat",
+ "border-image-source",
+ "border-left-style",
+ "border-right-style",
+ "border-top-style",
+ "-moz-box-align",
+ "box-decoration-break",
+ "-moz-box-direction",
+ "-moz-box-ordinal-group",
+ "-moz-box-orient",
+ "-moz-box-pack",
+ "box-sizing",
+ "caption-side",
+ "clear",
+ "clip-rule",
+ "color-interpolation",
+ "color-interpolation-filters",
+ "color-scheme",
+ "column-fill",
+ "column-rule-style",
+ "column-span",
+ "contain",
+ "content",
+ "counter-increment",
+ "counter-reset",
+ "counter-set",
+ "cursor",
+ "direction",
+ "dominant-baseline",
+ "empty-cells",
+ "fill-rule",
+ "flex-direction",
+ "flex-wrap",
+ "float",
+ "-moz-float-edge",
+ "font-family",
+ "font-feature-settings",
+ "font-kerning",
+ "font-language-override",
+ "font-palette",
+ "font-style",
+ "font-synthesis-weight",
+ "font-synthesis-style",
+ "font-synthesis-small-caps",
+ "font-synthesis-position",
+ "font-variant-alternates",
+ "font-variant-caps",
+ "font-variant-east-asian",
+ "font-variant-emoji",
+ "font-variant-ligatures",
+ "font-variant-numeric",
+ "font-variant-position",
+ "-moz-force-broken-image-icon",
+ "forced-color-adjust",
+ "grid-auto-columns",
+ "grid-auto-flow",
+ "grid-auto-rows",
+ "grid-column-end",
+ "grid-column-start",
+ "grid-row-end",
+ "grid-row-start",
+ "grid-template-areas",
+ "grid-template-columns",
+ "grid-template-rows",
+ "hyphenate-character",
+ "hyphens",
+ "image-orientation",
+ "image-rendering",
+ "ime-mode",
+ "-moz-inert",
+ "initial-letter",
+ "isolation",
+ "justify-content",
+ "justify-items",
+ "justify-self",
+ "justify-tracks",
+ "line-break",
+ "list-style-image",
+ "list-style-position",
+ "list-style-type",
+ "marker-end",
+ "marker-mid",
+ "marker-start",
+ "mask-clip",
+ "mask-composite",
+ "mask-image",
+ "mask-mode",
+ "mask-origin",
+ "mask-repeat",
+ "mask-type",
+ "masonry-auto-flow",
+ "mix-blend-mode",
+ "object-fit",
+ "-moz-orient",
+ "-moz-osx-font-smoothing",
+ "-moz-subtree-hidden-only-visually",
+ "outline-style",
+ "overflow-anchor",
+ "overflow-block",
+ "overflow-clip-box-block",
+ "overflow-clip-box-inline",
+ "overflow-inline",
+ "overflow-wrap",
+ "overflow-x",
+ "overflow-y",
+ "overscroll-behavior-inline",
+ "overscroll-behavior-block",
+ "overscroll-behavior-x",
+ "overscroll-behavior-y",
+ "break-after",
+ "break-before",
+ "break-inside",
+ "page",
+ "paint-order",
+ "pointer-events",
+ "position",
+ "print-color-adjust",
+ "quotes",
+ "resize",
+ "ruby-align",
+ "ruby-position",
+ "scroll-behavior",
+ "scroll-snap-align",
+ "scroll-snap-stop",
+ "scroll-snap-type",
+ "shape-rendering",
+ "scrollbar-gutter",
+ "scrollbar-width",
+ "stroke-linecap",
+ "stroke-linejoin",
+ "table-layout",
+ "text-align",
+ "text-align-last",
+ "text-anchor",
+ "text-combine-upright",
+ "text-decoration-line",
+ "text-decoration-skip-ink",
+ "text-decoration-style",
+ "text-emphasis-position",
+ "text-emphasis-style",
+ "text-justify",
+ "text-orientation",
+ "text-overflow",
+ "text-rendering",
+ "-moz-text-size-adjust",
+ "-webkit-text-security",
+ "-webkit-text-stroke-width",
+ "text-transform",
+ "text-underline-position",
+ "text-wrap-mode",
+ "text-wrap-style",
+ "touch-action",
+ "transform-box",
+ "transform-style",
+ "unicode-bidi",
+ "-moz-user-focus",
+ "-moz-user-input",
+ "-moz-user-modify",
+ "user-select",
+ "vector-effect",
+ "visibility",
+ "white-space-collapse",
+ "will-change",
+ "-moz-window-dragging",
+ "word-break",
+ "writing-mode",
+ ]),
+ ],
+ [
+ "none",
+ new Set([
+ "animation-composition",
+ "animation-delay",
+ "animation-direction",
+ "animation-duration",
+ "animation-fill-mode",
+ "animation-iteration-count",
+ "animation-name",
+ "animation-play-state",
+ "animation-timeline",
+ "animation-timing-function",
+ "block-size",
+ "border-block-end-color",
+ "border-block-end-style",
+ "border-block-end-width",
+ "border-block-start-color",
+ "border-block-start-style",
+ "border-block-start-width",
+ "border-inline-end-color",
+ "border-inline-end-style",
+ "border-inline-end-width",
+ "border-inline-start-color",
+ "border-inline-start-style",
+ "border-inline-start-width",
+ "container-name",
+ "container-type",
+ "contain-intrinsic-block-size",
+ "contain-intrinsic-inline-size",
+ "contain-intrinsic-height",
+ "contain-intrinsic-width",
+ "content-visibility",
+ "-moz-context-properties",
+ "-moz-control-character-visibility",
+ "-moz-default-appearance",
+ "-moz-theme",
+ "display",
+ "font-optical-sizing",
+ "inline-size",
+ "inset-block-end",
+ "inset-block-start",
+ "inset-inline-end",
+ "inset-inline-start",
+ "margin-block-end",
+ "margin-block-start",
+ "margin-inline-end",
+ "margin-inline-start",
+ "math-style",
+ "max-block-size",
+ "max-inline-size",
+ "min-block-size",
+ "-moz-min-font-size-ratio",
+ "min-inline-size",
+ "padding-block-end",
+ "padding-block-start",
+ "padding-inline-end",
+ "padding-inline-start",
+ "page-orientation",
+ "math-depth",
+ "-moz-box-collapse",
+ "-moz-top-layer",
+ "scroll-timeline-axis",
+ "scroll-timeline-name",
+ "size",
+ "transition-delay",
+ "transition-duration",
+ "transition-property",
+ "transition-timing-function",
+ "view-timeline-axis",
+ "view-timeline-inset",
+ "view-timeline-name",
+ "-moz-window-shadow",
+ ]),
+ ],
+ [
+ "color",
+ new Set([
+ "background-color",
+ "border-bottom-color",
+ "border-left-color",
+ "border-right-color",
+ "border-top-color",
+ "accent-color",
+ "caret-color",
+ "color",
+ "column-rule-color",
+ "flood-color",
+ "lighting-color",
+ "outline-color",
+ "scrollbar-color",
+ "stop-color",
+ "text-decoration-color",
+ "text-emphasis-color",
+ "-webkit-text-fill-color",
+ "-webkit-text-stroke-color",
+ ]),
+ ],
+ [
+ "custom",
+ new Set([
+ "backdrop-filter",
+ "background-position-x",
+ "background-position-y",
+ "background-size",
+ "border-bottom-width",
+ "border-image-slice",
+ "border-image-outset",
+ "border-image-width",
+ "border-left-width",
+ "border-right-width",
+ "border-spacing",
+ "border-top-width",
+ "clip",
+ "clip-path",
+ "column-count",
+ "column-rule-width",
+ "d",
+ "filter",
+ "font-stretch",
+ "font-variation-settings",
+ "font-weight",
+ "mask-position-x",
+ "mask-position-y",
+ "mask-size",
+ "object-position",
+ "offset-anchor",
+ "offset-path",
+ "offset-position",
+ "offset-rotate",
+ "order",
+ "perspective-origin",
+ "rotate",
+ "scale",
+ "shape-outside",
+ "stroke-dasharray",
+ "transform",
+ "transform-origin",
+ "translate",
+ "-moz-window-transform",
+ "-moz-window-transform-origin",
+ "-webkit-line-clamp",
+ ]),
+ ],
+ [
+ "coord",
+ new Set([
+ "border-bottom-left-radius",
+ "border-bottom-right-radius",
+ "border-top-left-radius",
+ "border-top-right-radius",
+ "border-start-start-radius",
+ "border-start-end-radius",
+ "border-end-start-radius",
+ "border-end-end-radius",
+ "bottom",
+ "column-gap",
+ "column-width",
+ "cx",
+ "cy",
+ "flex-basis",
+ "height",
+ "left",
+ "letter-spacing",
+ "line-height",
+ "margin-bottom",
+ "margin-left",
+ "margin-right",
+ "margin-top",
+ "max-height",
+ "max-width",
+ "min-height",
+ "min-width",
+ "offset-distance",
+ "padding-bottom",
+ "padding-left",
+ "padding-right",
+ "padding-top",
+ "perspective",
+ "r",
+ "rx",
+ "ry",
+ "right",
+ "row-gap",
+ "scroll-padding-block-start",
+ "scroll-padding-block-end",
+ "scroll-padding-inline-start",
+ "scroll-padding-inline-end",
+ "scroll-padding-top",
+ "scroll-padding-right",
+ "scroll-padding-bottom",
+ "scroll-padding-left",
+ "scroll-margin-block-start",
+ "scroll-margin-block-end",
+ "scroll-margin-inline-start",
+ "scroll-margin-inline-end",
+ "scroll-margin-top",
+ "scroll-margin-right",
+ "scroll-margin-bottom",
+ "scroll-margin-left",
+ "shape-margin",
+ "stroke-dashoffset",
+ "stroke-width",
+ "tab-size",
+ "text-indent",
+ "text-decoration-thickness",
+ "text-underline-offset",
+ "top",
+ "vertical-align",
+ "width",
+ "word-spacing",
+ "x",
+ "y",
+ "z-index",
+ ]),
+ ],
+ [
+ "float",
+ new Set([
+ "-moz-box-flex",
+ "fill-opacity",
+ "flex-grow",
+ "flex-shrink",
+ "flood-opacity",
+ "font-size-adjust",
+ "opacity",
+ "shape-image-threshold",
+ "stop-opacity",
+ "stroke-miterlimit",
+ "stroke-opacity",
+ "zoom",
+ "-moz-window-opacity",
+ ]),
+ ],
+ ["shadow", new Set(["box-shadow", "text-shadow"])],
+ ["paintServer", new Set(["fill", "stroke"])],
+ [
+ "length",
+ new Set([
+ "font-size",
+ "outline-offset",
+ "outline-width",
+ "overflow-clip-margin",
+ "-moz-window-input-region-margin",
+ ]),
+ ],
+];
diff --git a/devtools/server/actors/animation.js b/devtools/server/actors/animation.js
new file mode 100644
index 0000000000..d8de85fd73
--- /dev/null
+++ b/devtools/server/actors/animation.js
@@ -0,0 +1,906 @@
+/* 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";
+
+/**
+ * Set of actors that expose the Web Animations API to devtools protocol
+ * clients.
+ *
+ * The |Animations| actor is the main entry point. It is used to discover
+ * animation players on given nodes.
+ * There should only be one instance per devtools server.
+ *
+ * The |AnimationPlayer| actor provides attributes and methods to inspect an
+ * animation as well as pause/resume/seek it.
+ *
+ * The Web Animation spec implementation is ongoing in Gecko, and so this set
+ * of actors should evolve when the implementation progresses.
+ *
+ * References:
+ * - WebAnimation spec:
+ * http://drafts.csswg.org/web-animations/
+ * - WebAnimation WebIDL files:
+ * /dom/webidl/Animation*.webidl
+ */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ animationPlayerSpec,
+ animationsSpec,
+} = require("resource://devtools/shared/specs/animation.js");
+
+const {
+ ANIMATION_TYPE_FOR_LONGHANDS,
+} = require("resource://devtools/server/actors/animation-type-longhand.js");
+
+// Types of animations.
+const ANIMATION_TYPES = {
+ CSS_ANIMATION: "cssanimation",
+ CSS_TRANSITION: "csstransition",
+ SCRIPT_ANIMATION: "scriptanimation",
+ UNKNOWN: "unknown",
+};
+exports.ANIMATION_TYPES = ANIMATION_TYPES;
+
+function getAnimationTypeForLonghand(property) {
+ // If this is a custom property, return "custom" for now as it's not straightforward
+ // to retrieve the proper animation type.
+ // TODO: We could compute the animation type from the registered property syntax (Bug 1875435)
+ if (property.startsWith("--")) {
+ return "custom";
+ }
+
+ for (const [type, props] of ANIMATION_TYPE_FOR_LONGHANDS) {
+ if (props.has(property)) {
+ return type;
+ }
+ }
+ throw new Error("Unknown longhand property name");
+}
+exports.getAnimationTypeForLonghand = getAnimationTypeForLonghand;
+
+/**
+ * The AnimationPlayerActor provides information about a given animation: its
+ * startTime, currentTime, current state, etc.
+ *
+ * Since the state of a player changes as the animation progresses it is often
+ * useful to call getCurrentState at regular intervals to get the current state.
+ *
+ * This actor also allows playing, pausing and seeking the animation.
+ */
+class AnimationPlayerActor extends Actor {
+ /**
+ * @param {AnimationsActor} The main AnimationsActor instance
+ * @param {AnimationPlayer} The player object returned by getAnimationPlayers
+ * @param {Number} Time which animation created
+ */
+ constructor(animationsActor, player, createdTime) {
+ super(animationsActor.conn, animationPlayerSpec);
+
+ this.onAnimationMutation = this.onAnimationMutation.bind(this);
+
+ this.walker = animationsActor.walker;
+ this.player = player;
+
+ // Listen to animation mutations on the node to alert the front when the
+ // current animation changes.
+ // If the node is a pseudo-element, then we listen on its parent with
+ // subtree:true (there's no risk of getting too many notifications in
+ // onAnimationMutation since we filter out events that aren't for the
+ // current animation).
+ this.observer = new this.window.MutationObserver(this.onAnimationMutation);
+ if (this.isPseudoElement) {
+ this.observer.observe(this.node.parentElement, {
+ animations: true,
+ subtree: true,
+ });
+ } else {
+ this.observer.observe(this.node, { animations: true });
+ }
+
+ this.createdTime = createdTime;
+ this.currentTimeAtCreated = player.currentTime;
+ }
+
+ destroy() {
+ // Only try to disconnect the observer if it's not already dead (i.e. if the
+ // container view hasn't navigated since).
+ if (this.observer && !Cu.isDeadWrapper(this.observer)) {
+ this.observer.disconnect();
+ }
+ this.player = this.observer = this.walker = null;
+
+ super.destroy();
+ }
+
+ get isPseudoElement() {
+ return !!this.player.effect.pseudoElement;
+ }
+
+ get pseudoElemenName() {
+ if (!this.isPseudoElement) {
+ return null;
+ }
+
+ return `_moz_generated_content_${this.player.effect.pseudoElement.replace(
+ /^::/,
+ ""
+ )}`;
+ }
+
+ get node() {
+ if (!this.isPseudoElement) {
+ return this.player.effect.target;
+ }
+
+ const pseudoElementName = this.pseudoElemenName;
+ const originatingElem = this.player.effect.target;
+ const treeWalker = this.walker.getDocumentWalker(originatingElem);
+
+ // When the animated node is a pseudo-element, we need to walk the children
+ // of the target node and look for it.
+ for (
+ let next = treeWalker.firstChild();
+ next;
+ next = treeWalker.nextSibling()
+ ) {
+ if (next.nodeName === pseudoElementName) {
+ return next;
+ }
+ }
+
+ console.warn(
+ `Pseudo element ${this.player.effect.pseudoElement} is not found`
+ );
+ return originatingElem;
+ }
+
+ get document() {
+ return this.node.ownerDocument;
+ }
+
+ get window() {
+ return this.document.defaultView;
+ }
+
+ /**
+ * Release the actor, when it isn't needed anymore.
+ * Protocol.js uses this release method to call the destroy method.
+ */
+ release() {}
+
+ form(detail) {
+ const data = this.getCurrentState();
+ data.actor = this.actorID;
+
+ // If we know the WalkerActor, and if the animated node is known by it, then
+ // return its corresponding NodeActor ID too.
+ if (this.walker && this.walker.hasNode(this.node)) {
+ data.animationTargetNodeActorID = this.walker.getNode(this.node).actorID;
+ }
+
+ return data;
+ }
+
+ isCssAnimation(player = this.player) {
+ return player instanceof this.window.CSSAnimation;
+ }
+
+ isCssTransition(player = this.player) {
+ return player instanceof this.window.CSSTransition;
+ }
+
+ isScriptAnimation(player = this.player) {
+ return (
+ player instanceof this.window.Animation &&
+ !(
+ player instanceof this.window.CSSAnimation ||
+ player instanceof this.window.CSSTransition
+ )
+ );
+ }
+
+ getType() {
+ if (this.isCssAnimation()) {
+ return ANIMATION_TYPES.CSS_ANIMATION;
+ } else if (this.isCssTransition()) {
+ return ANIMATION_TYPES.CSS_TRANSITION;
+ } else if (this.isScriptAnimation()) {
+ return ANIMATION_TYPES.SCRIPT_ANIMATION;
+ }
+
+ return ANIMATION_TYPES.UNKNOWN;
+ }
+
+ /**
+ * Get the name of this animation. This can be either the animation.id
+ * property if it was set, or the keyframe rule name or the transition
+ * property.
+ * @return {String}
+ */
+ getName() {
+ if (this.player.id) {
+ return this.player.id;
+ } else if (this.isCssAnimation()) {
+ return this.player.animationName;
+ } else if (this.isCssTransition()) {
+ return this.player.transitionProperty;
+ }
+
+ return "";
+ }
+
+ /**
+ * Get the animation duration from this player, in milliseconds.
+ * @return {Number}
+ */
+ getDuration() {
+ return this.player.effect.getComputedTiming().duration;
+ }
+
+ /**
+ * Get the animation delay from this player, in milliseconds.
+ * @return {Number}
+ */
+ getDelay() {
+ return this.player.effect.getComputedTiming().delay;
+ }
+
+ /**
+ * Get the animation endDelay from this player, in milliseconds.
+ * @return {Number}
+ */
+ getEndDelay() {
+ return this.player.effect.getComputedTiming().endDelay;
+ }
+
+ /**
+ * Get the animation iteration count for this player. That is, how many times
+ * is the animation scheduled to run.
+ * @return {Number} The number of iterations, or null if the animation repeats
+ * infinitely.
+ */
+ getIterationCount() {
+ const iterations = this.player.effect.getComputedTiming().iterations;
+ return iterations === Infinity ? null : iterations;
+ }
+
+ /**
+ * Get the animation iterationStart from this player, in ratio.
+ * That is offset of starting position of the animation.
+ * @return {Number}
+ */
+ getIterationStart() {
+ return this.player.effect.getComputedTiming().iterationStart;
+ }
+
+ /**
+ * Get the animation easing from this player.
+ * @return {String}
+ */
+ getEasing() {
+ return this.player.effect.getComputedTiming().easing;
+ }
+
+ /**
+ * Get the animation fill mode from this player.
+ * @return {String}
+ */
+ getFill() {
+ return this.player.effect.getComputedTiming().fill;
+ }
+
+ /**
+ * Get the animation direction from this player.
+ * @return {String}
+ */
+ getDirection() {
+ return this.player.effect.getComputedTiming().direction;
+ }
+
+ /**
+ * Get animation-timing-function from animated element if CSS Animations.
+ * @return {String}
+ */
+ getAnimationTimingFunction() {
+ if (!this.isCssAnimation()) {
+ return null;
+ }
+
+ let pseudo = null;
+ let target = this.player.effect.target;
+ if (target.type) {
+ // Animated element is a pseudo element.
+ pseudo = target.type;
+ target = target.element;
+ }
+ return this.window.getComputedStyle(target, pseudo).animationTimingFunction;
+ }
+
+ getPropertiesCompositorStatus() {
+ const properties = this.player.effect.getProperties();
+ return properties.map(prop => {
+ return {
+ property: prop.property,
+ runningOnCompositor: prop.runningOnCompositor,
+ warning: prop.warning,
+ };
+ });
+ }
+
+ /**
+ * Return the current start of the Animation.
+ * @return {Object}
+ */
+ getState() {
+ const compositorStatus = this.getPropertiesCompositorStatus();
+ // Note that if you add a new property to the state object, make sure you
+ // add the corresponding property in the AnimationPlayerFront' initialState
+ // getter.
+ return {
+ type: this.getType(),
+ // startTime is null whenever the animation is paused or waiting to start.
+ startTime: this.player.startTime,
+ currentTime: this.player.currentTime,
+ playState: this.player.playState,
+ playbackRate: this.player.playbackRate,
+ name: this.getName(),
+ duration: this.getDuration(),
+ delay: this.getDelay(),
+ endDelay: this.getEndDelay(),
+ iterationCount: this.getIterationCount(),
+ iterationStart: this.getIterationStart(),
+ fill: this.getFill(),
+ easing: this.getEasing(),
+ direction: this.getDirection(),
+ animationTimingFunction: this.getAnimationTimingFunction(),
+ // animation is hitting the fast path or not. Returns false whenever the
+ // animation is paused as it is taken off the compositor then.
+ isRunningOnCompositor: compositorStatus.some(
+ propState => propState.runningOnCompositor
+ ),
+ propertyState: compositorStatus,
+ // The document timeline's currentTime is being sent along too. This is
+ // not strictly related to the node's animationPlayer, but is useful to
+ // know the current time of the animation with respect to the document's.
+ documentCurrentTime: this.node.ownerDocument.timeline.currentTime,
+ // The time which this animation created.
+ createdTime: this.createdTime,
+ // The time which an animation's current time when this animation has created.
+ currentTimeAtCreated: this.currentTimeAtCreated,
+ properties: this.getProperties(),
+ };
+ }
+
+ /**
+ * Get the current state of the AnimationPlayer (currentTime, playState, ...).
+ * Note that the initial state is returned as the form of this actor when it
+ * is initialized.
+ * This protocol method only returns a trimed down version of this state in
+ * case some properties haven't changed since last time (since the front can
+ * reconstruct those). If you want the full state, use the getState method.
+ * @return {Object}
+ */
+ getCurrentState() {
+ const newState = this.getState();
+
+ // If we've saved a state before, compare and only send what has changed.
+ // It's expected of the front to also save old states to re-construct the
+ // full state when an incomplete one is received.
+ // This is to minimize protocol traffic.
+ let sentState = {};
+ if (this.currentState) {
+ for (const key in newState) {
+ if (
+ typeof this.currentState[key] === "undefined" ||
+ this.currentState[key] !== newState[key]
+ ) {
+ sentState[key] = newState[key];
+ }
+ }
+ } else {
+ sentState = newState;
+ }
+ this.currentState = newState;
+
+ return sentState;
+ }
+
+ /**
+ * Executed when the current animation changes, used to emit the new state
+ * the the front.
+ */
+ onAnimationMutation(mutations) {
+ const isCurrentAnimation = animation => animation === this.player;
+ const hasCurrentAnimation = animations =>
+ animations.some(isCurrentAnimation);
+ let hasChanged = false;
+
+ for (const { removedAnimations, changedAnimations } of mutations) {
+ if (hasCurrentAnimation(removedAnimations)) {
+ // Reset the local copy of the state on removal, since the animation can
+ // be kept on the client and re-added, its state needs to be sent in
+ // full.
+ this.currentState = null;
+ }
+
+ if (hasCurrentAnimation(changedAnimations)) {
+ // Only consider the state has having changed if any of effect timing properties,
+ // animationTimingFunction or playbackRate has changed.
+ const newState = this.getState();
+ const oldState = this.currentState;
+ hasChanged =
+ newState.delay !== oldState.delay ||
+ newState.iterationCount !== oldState.iterationCount ||
+ newState.iterationStart !== oldState.iterationStart ||
+ newState.duration !== oldState.duration ||
+ newState.endDelay !== oldState.endDelay ||
+ newState.direction !== oldState.direction ||
+ newState.easing !== oldState.easing ||
+ newState.fill !== oldState.fill ||
+ newState.animationTimingFunction !==
+ oldState.animationTimingFunction ||
+ newState.playbackRate !== oldState.playbackRate;
+ break;
+ }
+ }
+
+ if (hasChanged) {
+ this.emit("changed", this.getCurrentState());
+ }
+ }
+
+ /**
+ * Get data about the animated properties of this animation player.
+ * @return {Array} Returns a list of animated properties.
+ * Each property contains a list of values, their offsets and distances.
+ */
+ getProperties() {
+ const properties = this.player.effect.getProperties().map(property => {
+ return { name: property.property, values: property.values };
+ });
+
+ const DOMWindowUtils = this.window.windowUtils;
+
+ // Fill missing keyframe with computed value.
+ for (const property of properties) {
+ let underlyingValue = null;
+ // Check only 0% and 100% keyframes.
+ [0, property.values.length - 1].forEach(index => {
+ const values = property.values[index];
+ if (values.value !== undefined) {
+ return;
+ }
+ if (!underlyingValue) {
+ let pseudo = null;
+ let target = this.player.effect.target;
+ if (target.type) {
+ // This target is a pseudo element.
+ pseudo = target.type;
+ target = target.element;
+ }
+ const value = DOMWindowUtils.getUnanimatedComputedStyle(
+ target,
+ pseudo,
+ property.name,
+ DOMWindowUtils.FLUSH_NONE
+ );
+ const animationType = getAnimationTypeForLonghand(property.name);
+ underlyingValue =
+ animationType === "float" ? parseFloat(value, 10) : value;
+ }
+ values.value = underlyingValue;
+ });
+ }
+
+ // Calculate the distance.
+ for (const property of properties) {
+ const propertyName = property.name;
+ const maxObject = { distance: -1 };
+ for (let i = 0; i < property.values.length - 1; i++) {
+ const value1 = property.values[i].value;
+ for (let j = i + 1; j < property.values.length; j++) {
+ const value2 = property.values[j].value;
+ const distance = this.getDistance(
+ this.node,
+ propertyName,
+ value1,
+ value2,
+ DOMWindowUtils
+ );
+ if (maxObject.distance >= distance) {
+ continue;
+ }
+ maxObject.distance = distance;
+ maxObject.value1 = value1;
+ maxObject.value2 = value2;
+ }
+ }
+ if (maxObject.distance === 0) {
+ // Distance is zero means that no values change or can't calculate the distance.
+ // In this case, we use the keyframe offset as the distance.
+ property.values.reduce((previous, current) => {
+ // If the current value is same as previous value, use previous distance.
+ current.distance =
+ current.value === previous.value
+ ? previous.distance
+ : current.offset;
+ return current;
+ }, property.values[0]);
+ continue;
+ }
+ const baseValue =
+ maxObject.value1 < maxObject.value2
+ ? maxObject.value1
+ : maxObject.value2;
+ for (const values of property.values) {
+ const value = values.value;
+ const distance = this.getDistance(
+ this.node,
+ propertyName,
+ baseValue,
+ value,
+ DOMWindowUtils
+ );
+ values.distance = distance / maxObject.distance;
+ }
+ }
+ return properties;
+ }
+
+ /**
+ * Get the animation types for a given list of CSS property names.
+ * @param {Array} propertyNames - CSS property names (e.g. background-color)
+ * @return {Object} Returns animation types (e.g. {"background-color": "rgb(0, 0, 0)"}.
+ */
+ getAnimationTypes(propertyNames) {
+ const animationTypes = {};
+ for (const propertyName of propertyNames) {
+ animationTypes[propertyName] = getAnimationTypeForLonghand(propertyName);
+ }
+ return animationTypes;
+ }
+
+ /**
+ * Returns the distance of between value1, value2.
+ * @param {Object} target - dom element
+ * @param {String} propertyName - e.g. transform
+ * @param {String} value1 - e.g. translate(0px)
+ * @param {String} value2 - e.g. translate(10px)
+ * @param {Object} DOMWindowUtils
+ * @param {float} distance
+ */
+ getDistance(target, propertyName, value1, value2, DOMWindowUtils) {
+ if (value1 === value2) {
+ return 0;
+ }
+ try {
+ const distance = DOMWindowUtils.computeAnimationDistance(
+ target,
+ propertyName,
+ value1,
+ value2
+ );
+ return distance;
+ } catch (e) {
+ // We can't compute the distance such the 'discrete' animation,
+ // 'auto' keyword and so on.
+ return 0;
+ }
+ }
+}
+
+exports.AnimationPlayerActor = AnimationPlayerActor;
+
+/**
+ * The Animations actor lists animation players for a given node.
+ */
+exports.AnimationsActor = class AnimationsActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, animationsSpec);
+ this.targetActor = targetActor;
+
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+ this.onNavigate = this.onNavigate.bind(this);
+ this.onAnimationMutation = this.onAnimationMutation.bind(this);
+
+ this.allAnimationsPaused = false;
+ this.targetActor.on("will-navigate", this.onWillNavigate);
+ this.targetActor.on("navigate", this.onNavigate);
+ }
+
+ destroy() {
+ super.destroy();
+ this.targetActor.off("will-navigate", this.onWillNavigate);
+ this.targetActor.off("navigate", this.onNavigate);
+
+ this.stopAnimationPlayerUpdates();
+ this.targetActor = this.observer = this.actors = this.walker = null;
+ }
+
+ /**
+ * Clients can optionally call this with a reference to their WalkerActor.
+ * If they do, then AnimationPlayerActor's forms are going to also include
+ * NodeActor IDs when the corresponding NodeActors do exist.
+ * This, in turns, is helpful for clients to avoid having to go back once more
+ * to the server to get a NodeActor for a particular animation.
+ * @param {WalkerActor} walker
+ */
+ setWalkerActor(walker) {
+ this.walker = walker;
+ }
+
+ /**
+ * Retrieve the list of AnimationPlayerActor actors for currently running
+ * animations on a node and its descendants.
+ * Note that calling this method a second time will destroy all previously
+ * retrieved AnimationPlayerActors. Indeed, the lifecycle of these actors
+ * is managed here on the server and tied to getAnimationPlayersForNode
+ * being called.
+ * @param {NodeActor} nodeActor The NodeActor as defined in
+ * /devtools/server/actors/inspector
+ */
+ getAnimationPlayersForNode(nodeActor) {
+ const animations = nodeActor.rawNode.getAnimations({ subtree: true });
+
+ // Destroy previously stored actors
+ if (this.actors) {
+ for (const actor of this.actors) {
+ actor.destroy();
+ }
+ }
+
+ this.actors = [];
+
+ for (const animation of animations) {
+ const createdTime = this.getCreatedTime(animation);
+ const actor = new AnimationPlayerActor(this, animation, createdTime);
+ this.actors.push(actor);
+ }
+
+ // When a front requests the list of players for a node, start listening
+ // for animation mutations on this node to send updates to the front, until
+ // either getAnimationPlayersForNode is called again or
+ // stopAnimationPlayerUpdates is called.
+ this.stopAnimationPlayerUpdates();
+ // ownerGlobal doesn't exist in content privileged windows.
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ const win = nodeActor.rawNode.ownerDocument.defaultView;
+ this.observer = new win.MutationObserver(this.onAnimationMutation);
+ this.observer.observe(nodeActor.rawNode, {
+ animations: true,
+ subtree: true,
+ });
+
+ return this.actors;
+ }
+
+ onAnimationMutation(mutations) {
+ const eventData = [];
+ const readyPromises = [];
+
+ for (const { addedAnimations, removedAnimations } of mutations) {
+ for (const player of removedAnimations) {
+ // Note that animations are reported as removed either when they are
+ // actually removed from the node (e.g. css class removed) or when they
+ // are finished and don't have forwards animation-fill-mode.
+ // In the latter case, we don't send an event, because the corresponding
+ // animation can still be seeked/resumed, so we want the client to keep
+ // its reference to the AnimationPlayerActor.
+ if (player.playState !== "idle") {
+ continue;
+ }
+
+ const index = this.actors.findIndex(a => a.player === player);
+ if (index !== -1) {
+ eventData.push({
+ type: "removed",
+ player: this.actors[index],
+ });
+ this.actors.splice(index, 1);
+ }
+ }
+
+ for (const player of addedAnimations) {
+ // If the added player already exists, it means we previously filtered
+ // it out when it was reported as removed. So filter it out here too.
+ if (this.actors.find(a => a.player === player)) {
+ continue;
+ }
+
+ // If the added player has the same name and target node as a player we
+ // already have, it means it's a transition that's re-starting. So send
+ // a "removed" event for the one we already have.
+ const index = this.actors.findIndex(a => {
+ const isSameType = a.player.constructor === player.constructor;
+ const isSameName =
+ (a.isCssAnimation() &&
+ a.player.animationName === player.animationName) ||
+ (a.isCssTransition() &&
+ a.player.transitionProperty === player.transitionProperty);
+ const isSameNode = a.player.effect.target === player.effect.target;
+
+ return isSameType && isSameNode && isSameName;
+ });
+ if (index !== -1) {
+ eventData.push({
+ type: "removed",
+ player: this.actors[index],
+ });
+ this.actors.splice(index, 1);
+ }
+
+ const createdTime = this.getCreatedTime(player);
+ const actor = new AnimationPlayerActor(this, player, createdTime);
+ this.actors.push(actor);
+ eventData.push({
+ type: "added",
+ player: actor,
+ });
+ readyPromises.push(player.ready);
+ }
+ }
+
+ if (eventData.length) {
+ // Let's wait for all added animations to be ready before telling the
+ // front-end.
+ Promise.all(readyPromises).then(() => {
+ this.emit("mutations", eventData);
+ });
+ }
+ }
+
+ /**
+ * After the client has called getAnimationPlayersForNode for a given DOM
+ * node, the actor starts sending animation mutations for this node. If the
+ * client doesn't want this to happen anymore, it should call this method.
+ */
+ stopAnimationPlayerUpdates() {
+ if (this.observer && !Cu.isDeadWrapper(this.observer)) {
+ this.observer.disconnect();
+ }
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.stopAnimationPlayerUpdates();
+ }
+ }
+
+ onNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.allAnimationsPaused = false;
+ }
+ }
+
+ /**
+ * Pause given animations.
+ *
+ * @param {Array} actors A list of AnimationPlayerActor.
+ */
+ pauseSome(actors) {
+ for (const { player } of actors) {
+ this.pauseSync(player);
+ }
+
+ return this.waitForNextFrame(actors);
+ }
+
+ /**
+ * Play given animations.
+ *
+ * @param {Array} actors A list of AnimationPlayerActor.
+ */
+ playSome(actors) {
+ for (const { player } of actors) {
+ this.playSync(player);
+ }
+
+ return this.waitForNextFrame(actors);
+ }
+
+ /**
+ * Set the current time of several animations at the same time.
+ * @param {Array} players A list of AnimationPlayerActor.
+ * @param {Number} time The new currentTime.
+ * @param {Boolean} shouldPause Should the players be paused too.
+ */
+ setCurrentTimes(players, time, shouldPause) {
+ for (const actor of players) {
+ const player = actor.player;
+
+ if (shouldPause) {
+ player.startTime = null;
+ }
+
+ const currentTime =
+ player.playbackRate > 0
+ ? time - actor.createdTime
+ : actor.createdTime - time;
+ player.currentTime = currentTime * Math.abs(player.playbackRate);
+ }
+
+ return this.waitForNextFrame(players);
+ }
+
+ /**
+ * Set the playback rate of several animations at the same time.
+ * @param {Array} actors A list of AnimationPlayerActor.
+ * @param {Number} rate The new rate.
+ */
+ setPlaybackRates(players, rate) {
+ return Promise.all(
+ players.map(({ player }) => {
+ player.updatePlaybackRate(rate);
+ return player.ready;
+ })
+ );
+ }
+
+ /**
+ * Pause given player synchronously.
+ *
+ * @param {Object} player
+ */
+ pauseSync(player) {
+ player.startTime = null;
+ }
+
+ /**
+ * Play given player synchronously.
+ *
+ * @param {Object} player
+ */
+ playSync(player) {
+ if (!player.playbackRate) {
+ // We can not play with playbackRate zero.
+ return;
+ }
+
+ // Play animation in a synchronous fashion by setting the start time directly.
+ const currentTime = player.currentTime || 0;
+ player.startTime =
+ player.timeline.currentTime - currentTime / player.playbackRate;
+ }
+
+ /**
+ * Return created fime of given animaiton.
+ *
+ * @param {Object} animation
+ */
+ getCreatedTime(animation) {
+ return (
+ animation.startTime ||
+ animation.timeline.currentTime -
+ animation.currentTime / animation.playbackRate
+ );
+ }
+
+ /**
+ * Wait for next animation frame.
+ *
+ * @param {Array} actors
+ * @return {Promise} which waits for next frame
+ */
+ waitForNextFrame(actors) {
+ const promises = actors.map(actor => {
+ const doc = actor.document;
+ const win = actor.window;
+ const timeAtCurrent = doc.timeline.currentTime;
+
+ return new Promise(resolve => {
+ win.requestAnimationFrame(() => {
+ if (timeAtCurrent === doc.timeline.currentTime) {
+ win.requestAnimationFrame(resolve);
+ } else {
+ resolve();
+ }
+ });
+ });
+ });
+
+ return Promise.all(promises);
+ }
+};
diff --git a/devtools/server/actors/array-buffer.js b/devtools/server/actors/array-buffer.js
new file mode 100644
index 0000000000..940d17e166
--- /dev/null
+++ b/devtools/server/actors/array-buffer.js
@@ -0,0 +1,69 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ arrayBufferSpec,
+} = require("resource://devtools/shared/specs/array-buffer.js");
+
+/**
+ * Creates an actor for the specified ArrayBuffer.
+ *
+ * @param {DevToolsServerConnection} conn
+ * The server connection.
+ * @param buffer ArrayBuffer
+ * The buffer.
+ */
+class ArrayBufferActor extends Actor {
+ constructor(conn, buffer) {
+ super(conn, arrayBufferSpec);
+ this.buffer = buffer;
+ this.bufferLength = buffer.byteLength;
+ }
+
+ rawValue() {
+ return this.buffer;
+ }
+
+ form() {
+ return {
+ actor: this.actorID,
+ length: this.bufferLength,
+ // The `typeName` is read in the source spec when reading "sourcedata"
+ // which can either be an ArrayBuffer actor or a LongString actor.
+ typeName: this.typeName,
+ };
+ }
+
+ slice(start, count) {
+ const slice = new Uint8Array(this.buffer, start, count);
+ const parts = [];
+ let offset = 0;
+ const PortionSize = 0x6000; // keep it divisible by 3 for btoa() and join()
+ while (offset + PortionSize < count) {
+ parts.push(
+ btoa(
+ String.fromCharCode.apply(
+ null,
+ slice.subarray(offset, offset + PortionSize)
+ )
+ )
+ );
+ offset += PortionSize;
+ }
+ parts.push(
+ btoa(String.fromCharCode.apply(null, slice.subarray(offset, count)))
+ );
+ return {
+ from: this.actorID,
+ encoded: parts.join(""),
+ };
+ }
+}
+
+module.exports = {
+ ArrayBufferActor,
+};
diff --git a/devtools/server/actors/blackboxing.js b/devtools/server/actors/blackboxing.js
new file mode 100644
index 0000000000..49dfc8180d
--- /dev/null
+++ b/devtools/server/actors/blackboxing.js
@@ -0,0 +1,93 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ blackboxingSpec,
+} = require("resource://devtools/shared/specs/blackboxing.js");
+
+const {
+ SessionDataHelpers,
+} = require("resource://devtools/server/actors/watcher/SessionDataHelpers.jsm");
+const { SUPPORTED_DATA } = SessionDataHelpers;
+const { BLACKBOXING } = SUPPORTED_DATA;
+
+/**
+ * This actor manages the blackboxing of sources.
+ *
+ * Blackboxing data should be available as early as possible to new targets and
+ * will be forwarded to the WatcherActor to populate the shared session data available to
+ * all DevTools targets.
+ *
+ * @constructor
+ *
+ */
+class BlackboxingActor extends Actor {
+ constructor(watcherActor) {
+ super(watcherActor.conn, blackboxingSpec);
+ this.watcherActor = watcherActor;
+ }
+
+ /**
+ * Request to blackbox a new JS file either completely if no range is passed.
+ * Or only a precise subset of lines described by range attribute.
+ *
+ * @param {String} url
+ * Mandatory argument to mention what URL of JS file should be blackboxed.
+ * @param {Array<Objects>} ranges
+ * The whole file will be blackboxed if this array is empty.
+ * Each range is made of an object like this:
+ * {
+ * start: { line: 1, column: 1 },
+ * end: { line: 10, column: 10 },
+ * }
+ */
+ blackbox(url, ranges) {
+ if (!ranges.length) {
+ return this.watcherActor.addOrSetDataEntry(
+ BLACKBOXING,
+ [{ url, range: null }],
+ "add"
+ );
+ }
+ return this.watcherActor.addOrSetDataEntry(
+ BLACKBOXING,
+ ranges.map(range => {
+ return {
+ url,
+ range,
+ };
+ }),
+ "add"
+ );
+ }
+
+ /**
+ * Request to unblackbox some JS sources.
+ *
+ * See `blackbox` for more info.
+ */
+ unblackbox(url, ranges) {
+ if (!ranges.length) {
+ const existingRanges = (
+ this.watcherActor.getSessionDataForType(BLACKBOXING) || []
+ ).filter(entry => entry.url == url);
+
+ return this.watcherActor.removeDataEntry(BLACKBOXING, existingRanges);
+ }
+ return this.watcherActor.removeDataEntry(
+ BLACKBOXING,
+ ranges.map(range => {
+ return {
+ url,
+ range,
+ };
+ })
+ );
+ }
+}
+
+exports.BlackboxingActor = BlackboxingActor;
diff --git a/devtools/server/actors/breakpoint-list.js b/devtools/server/actors/breakpoint-list.js
new file mode 100644
index 0000000000..1f9d6c0bf9
--- /dev/null
+++ b/devtools/server/actors/breakpoint-list.js
@@ -0,0 +1,92 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ breakpointListSpec,
+} = require("resource://devtools/shared/specs/breakpoint-list.js");
+
+const {
+ SessionDataHelpers,
+} = require("resource://devtools/server/actors/watcher/SessionDataHelpers.jsm");
+const { SUPPORTED_DATA } = SessionDataHelpers;
+const { BREAKPOINTS, XHR_BREAKPOINTS, EVENT_BREAKPOINTS } = SUPPORTED_DATA;
+
+/**
+ * This actor manages the breakpoints list.
+ *
+ * Breakpoints should be available as early as possible to new targets and
+ * will be forwarded to the WatcherActor to populate the shared session data available to
+ * all DevTools targets.
+ *
+ * @constructor
+ *
+ */
+class BreakpointListActor extends Actor {
+ constructor(watcherActor) {
+ super(watcherActor.conn, breakpointListSpec);
+ this.watcherActor = watcherActor;
+ }
+
+ setBreakpoint(location, options) {
+ return this.watcherActor.addOrSetDataEntry(
+ BREAKPOINTS,
+ [{ location, options }],
+ "add"
+ );
+ }
+
+ removeBreakpoint(location, options) {
+ return this.watcherActor.removeDataEntry(BREAKPOINTS, [
+ { location, options },
+ ]);
+ }
+
+ /**
+ * Request to break on next XHR or Fetch request for a given URL and HTTP Method.
+ *
+ * @param {String} path
+ * If empty, will pause on regardless or the request's URL.
+ * Otherwise, will pause on any request whose URL includes this string.
+ * This is not specific to URL's path. It can match the URL origin.
+ * @param {String} method
+ * If set to "ANY", will pause regardless of which method is used.
+ * Otherwise, should be set to any valid HTTP Method (GET, POST, ...)
+ */
+ setXHRBreakpoint(path, method) {
+ return this.watcherActor.addOrSetDataEntry(
+ XHR_BREAKPOINTS,
+ [{ path, method }],
+ "add"
+ );
+ }
+
+ /**
+ * Stop breakpoint on requests we ask to break on via setXHRBreakpoint.
+ *
+ * See setXHRBreakpoint for arguments definition.
+ */
+ removeXHRBreakpoint(path, method) {
+ return this.watcherActor.removeDataEntry(XHR_BREAKPOINTS, [
+ { path, method },
+ ]);
+ }
+
+ /**
+ * Set the active breakpoints
+ *
+ * @param {Array<String>} ids
+ * An array of eventlistener breakpoint ids. These
+ * are unique identifiers for event breakpoints.
+ * See devtools/server/actors/utils/event-breakpoints.js
+ * for details.
+ */
+ async setActiveEventBreakpoints(ids) {
+ await this.watcherActor.addOrSetDataEntry(EVENT_BREAKPOINTS, ids, "set");
+ }
+}
+
+exports.BreakpointListActor = BreakpointListActor;
diff --git a/devtools/server/actors/breakpoint.js b/devtools/server/actors/breakpoint.js
new file mode 100644
index 0000000000..d1a469658c
--- /dev/null
+++ b/devtools/server/actors/breakpoint.js
@@ -0,0 +1,232 @@
+/* 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/. */
+
+/* global assert */
+
+"use strict";
+
+const {
+ logEvent,
+ getThrownMessage,
+} = require("resource://devtools/server/actors/utils/logEvent.js");
+
+/**
+ * Set breakpoints on all the given entry points with the given
+ * BreakpointActor as the handler.
+ *
+ * @param BreakpointActor actor
+ * The actor handling the breakpoint hits.
+ * @param Array entryPoints
+ * An array of objects of the form `{ script, offsets }`.
+ */
+function setBreakpointAtEntryPoints(actor, entryPoints) {
+ for (const { script, offsets } of entryPoints) {
+ actor.addScript(script, offsets);
+ }
+}
+
+exports.setBreakpointAtEntryPoints = setBreakpointAtEntryPoints;
+
+/**
+ * BreakpointActors are instantiated for each breakpoint that has been installed
+ * by the client. They are not true actors and do not communicate with the
+ * client directly, but encapsulate the DebuggerScript locations where the
+ * breakpoint is installed.
+ */
+class BreakpointActor {
+ constructor(threadActor, location) {
+ // A map from Debugger.Script instances to the offsets which the breakpoint
+ // has been set for in that script.
+ this.scripts = new Map();
+
+ this.threadActor = threadActor;
+ this.location = location;
+ this.options = null;
+ }
+
+ setOptions(options) {
+ const oldOptions = this.options;
+ this.options = options;
+
+ for (const [script, offsets] of this.scripts) {
+ this._newOffsetsOrOptions(script, offsets, oldOptions);
+ }
+ }
+
+ destroy() {
+ this.removeScripts();
+ this.options = null;
+ }
+
+ hasScript(script) {
+ return this.scripts.has(script);
+ }
+
+ /**
+ * Called when this same breakpoint is added to another Debugger.Script
+ * instance.
+ *
+ * @param script Debugger.Script
+ * The new source script on which the breakpoint has been set.
+ * @param offsets Array
+ * Any offsets in the script the breakpoint is associated with.
+ */
+ addScript(script, offsets) {
+ this.scripts.set(script, offsets.concat(this.scripts.get(offsets) || []));
+ this._newOffsetsOrOptions(script, offsets, null);
+ }
+
+ /**
+ * Remove the breakpoints from associated scripts and clear the script cache.
+ */
+ removeScripts() {
+ for (const [script] of this.scripts) {
+ script.clearBreakpoint(this);
+ }
+ this.scripts.clear();
+ }
+
+ /**
+ * Called on changes to this breakpoint's script offsets or options.
+ */
+ _newOffsetsOrOptions(script, offsets, oldOptions) {
+ // Clear any existing handler first in case this is called multiple times
+ // after options change.
+ for (const offset of offsets) {
+ script.clearBreakpoint(this, offset);
+ }
+
+ // In all other cases, this is used as a script breakpoint handler.
+ for (const offset of offsets) {
+ script.setBreakpoint(offset, this);
+ }
+ }
+
+ /**
+ * Check if this breakpoint has a condition that doesn't error and
+ * evaluates to true in frame.
+ *
+ * @param frame Debugger.Frame
+ * The frame to evaluate the condition in
+ * @returns Object
+ * - result: boolean|undefined
+ * True when the conditional breakpoint should trigger a pause,
+ * false otherwise. If the condition evaluation failed/killed,
+ * `result` will be `undefined`.
+ * - message: string
+ * If the condition throws, this is the thrown message.
+ */
+ checkCondition(frame, condition) {
+ // Ensure disabling breakpoint while evaluating the condition.
+ // All but exception breakpoint to report any exception when running the condition.
+ this.threadActor.insideClientEvaluation = {
+ disableBreaks: true,
+ reportExceptionsWhenBreaksAreDisabled: true,
+ };
+ let completion;
+
+ // Temporarily enable pause on exception when evaluating the condition.
+ const hadToEnablePauseOnException =
+ !this.threadActor.isPauseOnExceptionsEnabled();
+ try {
+ if (hadToEnablePauseOnException) {
+ this.threadActor.setPauseOnExceptions(true);
+ }
+ completion = frame.eval(condition, { hideFromDebugger: true });
+ } finally {
+ this.threadActor.insideClientEvaluation = null;
+ if (hadToEnablePauseOnException) {
+ this.threadActor.setPauseOnExceptions(false);
+ }
+ }
+ if (completion) {
+ if (completion.throw) {
+ // The evaluation failed and threw
+ return {
+ result: true,
+ message: getThrownMessage(completion),
+ };
+ } else if (completion.yield) {
+ assert(false, "Shouldn't ever get yield completions from an eval");
+ } else {
+ return { result: !!completion.return };
+ }
+ }
+ // The evaluation was killed (possibly by the slow script dialog)
+ return { result: undefined };
+ }
+
+ /**
+ * A function that the engine calls when a breakpoint has been hit.
+ *
+ * @param frame Debugger.Frame
+ * The stack frame that contained the breakpoint.
+ */
+ // eslint-disable-next-line complexity
+ hit(frame) {
+ if (this.threadActor.shouldSkipAnyBreakpoint) {
+ return undefined;
+ }
+
+ // Don't pause if we are currently stepping (in or over) or the frame is
+ // black-boxed.
+ const location = this.threadActor.sourcesManager.getFrameLocation(frame);
+ if (this.threadActor.sourcesManager.isFrameBlackBoxed(frame)) {
+ return undefined;
+ }
+
+ // If we're trying to pop this frame, and we see a breakpoint at
+ // the spot at which popping started, ignore it. See bug 970469.
+ const locationAtFinish = frame.onPop?.location;
+ if (
+ locationAtFinish &&
+ locationAtFinish.line === location.line &&
+ locationAtFinish.column === location.column
+ ) {
+ return undefined;
+ }
+
+ if (!this.threadActor.hasMoved(frame, "breakpoint")) {
+ return undefined;
+ }
+
+ const reason = { type: "breakpoint", actors: [this.actorID] };
+ const { condition, logValue } = this.options || {};
+
+ if (condition) {
+ const { result, message } = this.checkCondition(frame, condition);
+
+ // Don't pause if the result is falsey
+ if (!result) {
+ return undefined;
+ }
+
+ if (message) {
+ reason.type = "breakpointConditionThrown";
+ reason.message = message;
+ }
+ }
+
+ if (logValue) {
+ return logEvent({
+ threadActor: this.threadActor,
+ frame,
+ level: "logPoint",
+ expression: `[${logValue}]`,
+ });
+ }
+
+ return this.threadActor._pauseAndRespond(frame, reason);
+ }
+
+ delete() {
+ // Remove from the breakpoint store.
+ this.threadActor.breakpointActorMap.deleteActor(this.location);
+ // Remove the actual breakpoint from the associated scripts.
+ this.removeScripts();
+ this.destroy();
+ }
+}
+
+exports.BreakpointActor = BreakpointActor;
diff --git a/devtools/server/actors/changes.js b/devtools/server/actors/changes.js
new file mode 100644
index 0000000000..16e24cb2e7
--- /dev/null
+++ b/devtools/server/actors/changes.js
@@ -0,0 +1,125 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { changesSpec } = require("resource://devtools/shared/specs/changes.js");
+
+const TrackChangeEmitter = require("resource://devtools/server/actors/utils/track-change-emitter.js");
+
+/**
+ * The ChangesActor stores a stack of changes made by devtools on
+ * the document in the associated tab.
+ */
+class ChangesActor extends Actor {
+ /**
+ * Create a ChangesActor.
+ *
+ * @param {DevToolsServerConnection} conn
+ * The server connection.
+ * @param {TargetActor} targetActor
+ * The top-level Actor for this tab.
+ */
+ constructor(conn, targetActor) {
+ super(conn, changesSpec);
+ this.targetActor = targetActor;
+
+ this.onTrackChange = this.pushChange.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ TrackChangeEmitter.on("track-change", this.onTrackChange);
+ this.targetActor.on("will-navigate", this.onWillNavigate);
+
+ this.changes = [];
+ }
+
+ destroy() {
+ // Stop trying to emit RDP event on destruction.
+ this._changesHaveBeenRequested = false;
+ this.clearChanges();
+ this.targetActor.off("will-navigate", this.onWillNavigate);
+ TrackChangeEmitter.off("track-change", this.onTrackChange);
+ super.destroy();
+ }
+
+ start() {
+ /**
+ * This function currently does nothing and returns nothing. It exists only
+ * so that the client can trigger the creation of the ChangesActor through
+ * the front, without triggering side effects, and with a sensible semantic
+ * meaning.
+ */
+ }
+
+ changeCount() {
+ return this.changes.length;
+ }
+
+ change(index) {
+ if (index >= 0 && index < this.changes.length) {
+ // Return a copy of the change at index.
+ return Object.assign({}, this.changes[index]);
+ }
+ // No change at that index -- return undefined.
+ return undefined;
+ }
+
+ allChanges() {
+ /**
+ * This function is called by all change event consumers on the client
+ * to get their initial state synchronized with the ChangesActor. We
+ * set a flag when this function is called so we know that it's worthwhile
+ * to send events.
+ */
+ this._changesHaveBeenRequested = true;
+ return this.changes.slice();
+ }
+
+ /**
+ * Handler for "will-navigate" event from the browsing context. The event is fired for
+ * the host page and any nested resources, like iframes. The list of changes should be
+ * cleared only when the host page navigates, ignoring any of its iframes.
+ *
+ * TODO: Clear changes made within sources in iframes when they navigate. Bug 1513940
+ *
+ * @param {Object} eventData
+ * Event data with these properties:
+ * {
+ * window: Object // Window DOM object of the event source page
+ * isTopLevel: Boolean // true if the host page will navigate
+ * newURI: String // URI towards which the page will navigate
+ * request: Object // Request data.
+ * }
+ */
+ onWillNavigate(eventData) {
+ if (eventData.isTopLevel) {
+ this.clearChanges();
+ }
+ }
+
+ pushChange(change) {
+ this.changes.push(change);
+ if (this._changesHaveBeenRequested) {
+ this.emit("add-change", change);
+ }
+ }
+
+ popChange() {
+ const change = this.changes.pop();
+ if (this._changesHaveBeenRequested) {
+ this.emit("remove-change", change);
+ }
+ return change;
+ }
+
+ clearChanges() {
+ this.changes.length = 0;
+ if (this._changesHaveBeenRequested) {
+ this.emit("clear-changes");
+ }
+ }
+}
+
+exports.ChangesActor = ChangesActor;
diff --git a/devtools/server/actors/common.js b/devtools/server/actors/common.js
new file mode 100644
index 0000000000..cdf22ccae6
--- /dev/null
+++ b/devtools/server/actors/common.js
@@ -0,0 +1,110 @@
+/* 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 SourceLocation {
+ /**
+ * A SourceLocation represents a location in a source.
+ *
+ * @param SourceActor actor
+ * A SourceActor representing a source.
+ * @param Number line
+ * A line within the given source.
+ * @param Number column
+ * A column within the given line.
+ */
+ constructor(actor, line, column) {
+ this._connection = actor ? actor.conn : null;
+ this._actorID = actor ? actor.actorID : undefined;
+ this._line = line;
+ this._column = column;
+ }
+
+ get sourceActor() {
+ return this._connection ? this._connection.getActor(this._actorID) : null;
+ }
+
+ get url() {
+ return this.sourceActor.url;
+ }
+
+ get line() {
+ return this._line;
+ }
+
+ get column() {
+ return this._column;
+ }
+
+ get sourceUrl() {
+ return this.sourceActor.url;
+ }
+
+ equals(other) {
+ return (
+ this.sourceActor.url == other.sourceActor.url &&
+ this.line === other.line &&
+ (this.column === undefined ||
+ other.column === undefined ||
+ this.column === other.column)
+ );
+ }
+
+ toJSON() {
+ return {
+ source: this.sourceActor.form(),
+ line: this.line,
+ column: this.column,
+ };
+ }
+}
+
+exports.SourceLocation = SourceLocation;
+
+/**
+ * A method decorator that ensures the actor is in the expected state before
+ * proceeding. If the actor is not in the expected state, the decorated method
+ * returns a rejected promise.
+ *
+ * The actor's state must be at this.state property.
+ *
+ * @param String expectedState
+ * The expected state.
+ * @param String activity
+ * Additional info about what's going on.
+ * @param Function methodFunc
+ * The actor method to proceed with when the actor is in the expected
+ * state.
+ *
+ * @returns Function
+ * The decorated method.
+ */
+function expectState(expectedState, methodFunc, activity) {
+ return function (...args) {
+ if (this.state !== expectedState) {
+ const msg =
+ `Wrong state while ${activity}:` +
+ `Expected '${expectedState}', ` +
+ `but current state is '${this.state}'.`;
+ return Promise.reject(new Error(msg));
+ }
+
+ return methodFunc.apply(this, args);
+ };
+}
+
+exports.expectState = expectState;
+
+/**
+ * Autobind method from a `bridge` property set on some actors where the
+ * implementation is delegated to a separate class, and where `bridge` points
+ * to an instance of this class.
+ */
+function actorBridgeWithSpec(methodName) {
+ return function () {
+ return this.bridge[methodName].apply(this.bridge, arguments);
+ };
+}
+exports.actorBridgeWithSpec = actorBridgeWithSpec;
diff --git a/devtools/server/actors/compatibility/compatibility.js b/devtools/server/actors/compatibility/compatibility.js
new file mode 100644
index 0000000000..21ff68b310
--- /dev/null
+++ b/devtools/server/actors/compatibility/compatibility.js
@@ -0,0 +1,162 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ compatibilitySpec,
+} = require("resource://devtools/shared/specs/compatibility.js");
+
+loader.lazyGetter(this, "mdnCompatibility", () => {
+ const MDNCompatibility = require("resource://devtools/server/actors/compatibility/lib/MDNCompatibility.js");
+ const cssPropertiesCompatData = require("resource://devtools/shared/compatibility/dataset/css-properties.json");
+ return new MDNCompatibility(cssPropertiesCompatData);
+});
+
+class CompatibilityActor extends Actor {
+ /**
+ * Create a CompatibilityActor.
+ * CompatibilityActor is responsible for providing the compatibility information
+ * for the web page using the data from the Inspector and the `MDNCompatibility`
+ * and conveys them to the compatibility panel in the DevTool Inspector. Currently,
+ * the `CompatibilityActor` only detects compatibility issues in the CSS declarations
+ * but plans are in motion to extend it to evaluate compatibility information for
+ * HTML and JavaScript.
+ * The design below has the InspectorActor own the CompatibilityActor, but it's
+ * possible we will want to move it into it's own panel in the future.
+ *
+ * @param inspector
+ * The InspectorActor that owns this CompatibilityActor.
+ *
+ * @constructor
+ */
+ constructor(inspector) {
+ super(inspector.conn, compatibilitySpec);
+ this.inspector = inspector;
+ }
+
+ destroy() {
+ super.destroy();
+ this.inspector = null;
+ }
+
+ form() {
+ return {
+ actor: this.actorID,
+ };
+ }
+
+ getTraits() {
+ return {
+ traits: {},
+ };
+ }
+
+ /**
+ * Responsible for computing the compatibility issues for a list of CSS declaration blocks
+ *
+ * @param {Array<Array<Object>>} domRulesDeclarations: An array of arrays of CSS declaration object
+ * @param {string} domRulesDeclarations[][].name: Declaration name
+ * @param {string} domRulesDeclarations[][].value: Declaration value
+ * @param {Array<Object>} targetBrowsers: Array of target browsers () to be used to check CSS compatibility against
+ * @param {string} targetBrowsers[].id: Browser id as specified in `devtools/shared/compatibility/datasets/browser.json`
+ * @param {string} targetBrowsers[].name
+ * @param {string} targetBrowsers[].version
+ * @param {string} targetBrowsers[].status: Browser status - esr, current, beta, nightly
+ * @returns {Array<Array<Object>>} An Array of arrays of JSON objects with compatibility
+ * information in following form:
+ * {
+ * // Type of compatibility issue
+ * type: <string>,
+ * // The CSS declaration that has compatibility issues
+ * property: <string>,
+ * // Alias to the given CSS property
+ * alias: <Array>,
+ * // Link to MDN documentation for the particular CSS rule
+ * url: <string>,
+ * deprecated: <boolean>,
+ * experimental: <boolean>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ */
+ getCSSDeclarationBlockIssues(domRulesDeclarations, targetBrowsers) {
+ return domRulesDeclarations.map(declarationBlock =>
+ mdnCompatibility.getCSSDeclarationBlockIssues(
+ declarationBlock,
+ targetBrowsers
+ )
+ );
+ }
+
+ /**
+ * Responsible for computing the compatibility issues in the
+ * CSS declaration of the given node.
+ * @param NodeActor node
+ * @param targetBrowsers Array
+ * An Array of JSON object of target browser to check compatibility against in following form:
+ * {
+ * // Browser id as specified in `devtools/server/actors/compatibility/lib/datasets/browser.json`
+ * id: <string>,
+ * name: <string>,
+ * version: <string>,
+ * // Browser status - esr, current, beta, nightly
+ * status: <string>,
+ * }
+ * @returns An Array of JSON objects with compatibility information in following form:
+ * {
+ * // Type of compatibility issue
+ * type: <string>,
+ * // The CSS declaration that has compatibility issues
+ * property: <string>,
+ * // Alias to the given CSS property
+ * alias: <Array>,
+ * // Link to MDN documentation for the particular CSS rule
+ * url: <string>,
+ * deprecated: <boolean>,
+ * experimental: <boolean>,
+ * // An array of all the browsers that don't support the given CSS rule
+ * unsupportedBrowsers: <Array>,
+ * }
+ */
+ async getNodeCssIssues(node, targetBrowsers) {
+ const pageStyle = await this.inspector.getPageStyle();
+ const styles = await pageStyle.getApplied(node, {
+ skipPseudo: false,
+ });
+
+ const declarationBlocks = styles.entries
+ .map(({ rule }) => {
+ // Replace form() with a function to get minimal subset
+ // of declarations from StyleRuleActor when such a
+ // function lands in the StyleRuleActor
+ const declarations = rule.form().declarations;
+ if (!declarations) {
+ return null;
+ }
+ return declarations.filter(d => !d.commentOffsets);
+ })
+ .filter(declarations => declarations && declarations.length);
+
+ return declarationBlocks
+ .map(declarationBlock =>
+ mdnCompatibility.getCSSDeclarationBlockIssues(
+ declarationBlock,
+ targetBrowsers
+ )
+ )
+ .flat()
+ .reduce((issues, issue) => {
+ // Get rid of duplicate issue
+ return issues.find(
+ i => i.type === issue.type && i.property === issue.property
+ )
+ ? issues
+ : [...issues, issue];
+ }, []);
+ }
+}
+
+exports.CompatibilityActor = CompatibilityActor;
diff --git a/devtools/server/actors/compatibility/lib/MDNCompatibility.js b/devtools/server/actors/compatibility/lib/MDNCompatibility.js
new file mode 100644
index 0000000000..9975123103
--- /dev/null
+++ b/devtools/server/actors/compatibility/lib/MDNCompatibility.js
@@ -0,0 +1,327 @@
+/* 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 _SUPPORT_STATE_BROWSER_NOT_FOUND = "BROWSER_NOT_FOUND";
+const _SUPPORT_STATE_SUPPORTED = "SUPPORTED";
+const _SUPPORT_STATE_UNSUPPORTED = "UNSUPPORTED";
+const _SUPPORT_STATE_UNSUPPORTED_PREFIX_NEEDED = "UNSUPPORTED_PREFIX_NEEDED";
+
+loader.lazyRequireGetter(
+ this,
+ "COMPATIBILITY_ISSUE_TYPE",
+ "resource://devtools/shared/constants.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ ["getCompatNode", "getCompatTable"],
+ "resource://devtools/shared/compatibility/helpers.js",
+ true
+);
+
+const PREFIX_REGEX = /^-\w+-/;
+
+/**
+ * A class with methods used to query the MDN compatibility data for CSS properties and
+ * HTML nodes and attributes for specific browsers and versions.
+ */
+class MDNCompatibility {
+ /**
+ * Constructor.
+ *
+ * @param {JSON} cssPropertiesCompatData
+ * JSON of the compat data for CSS properties.
+ * https://github.com/mdn/browser-compat-data/tree/master/css/properties
+ */
+ constructor(cssPropertiesCompatData) {
+ this._cssPropertiesCompatData = cssPropertiesCompatData;
+ }
+
+ /**
+ * Return the CSS related compatibility issues from given CSS declaration blocks.
+ *
+ * @param {Array} declarations
+ * CSS declarations to check.
+ * e.g. [{ name: "background-color", value: "lime" }, ...]
+ * @param {Array} browsers
+ * Restrict compatibility checks to these browsers and versions.
+ * e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
+ * @return {Array} issues
+ */
+ getCSSDeclarationBlockIssues(declarations, browsers) {
+ const summaries = [];
+ for (const { name: property } of declarations) {
+ // Ignore CSS custom properties as any name is valid.
+ if (property.startsWith("--")) {
+ continue;
+ }
+
+ summaries.push(this._getCSSPropertyCompatSummary(browsers, property));
+ }
+
+ // Classify to aliases summaries and normal summaries.
+ const { aliasSummaries, normalSummaries } =
+ this._classifyCSSCompatSummaries(summaries, browsers);
+
+ // Finally, convert to CSS issues.
+ return this._toCSSIssues(normalSummaries.concat(aliasSummaries));
+ }
+
+ /**
+ * Classify the compatibility summaries that are able to get from
+ * `getCSSPropertyCompatSummary`.
+ * There are CSS properties that can specify the style with plural aliases such as
+ * `user-select`, aggregates those as the aliases summaries.
+ *
+ * @param {Array} summaries
+ * Assume the result of _getCSSPropertyCompatSummary().
+ * @param {Array} browsers
+ * All browsers that to check
+ * e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
+ * @return Object
+ * {
+ * aliasSummaries: Array of alias summary,
+ * normalSummaries: Array of normal summary
+ * }
+ */
+ _classifyCSSCompatSummaries(summaries, browsers) {
+ const aliasSummariesMap = new Map();
+ const normalSummaries = summaries.filter(s => {
+ const {
+ database,
+ invalid,
+ terms,
+ unsupportedBrowsers,
+ prefixNeededBrowsers,
+ } = s;
+
+ if (invalid) {
+ return true;
+ }
+
+ const alias = this._getAlias(database, terms);
+ if (!alias) {
+ return true;
+ }
+
+ if (!aliasSummariesMap.has(alias)) {
+ aliasSummariesMap.set(
+ alias,
+ Object.assign(s, {
+ property: alias,
+ aliases: [],
+ unsupportedBrowsers: browsers,
+ prefixNeededBrowsers: browsers,
+ })
+ );
+ }
+
+ // Update alias summary.
+ const terminal = terms.pop();
+ const aliasSummary = aliasSummariesMap.get(alias);
+ if (!aliasSummary.aliases.includes(terminal)) {
+ aliasSummary.aliases.push(terminal);
+ }
+ aliasSummary.unsupportedBrowsers =
+ aliasSummary.unsupportedBrowsers.filter(b =>
+ unsupportedBrowsers.includes(b)
+ );
+ aliasSummary.prefixNeededBrowsers =
+ aliasSummary.prefixNeededBrowsers.filter(b =>
+ prefixNeededBrowsers.includes(b)
+ );
+ return false;
+ });
+
+ const aliasSummaries = [...aliasSummariesMap.values()].map(s => {
+ s.prefixNeeded = s.prefixNeededBrowsers.length !== 0;
+ return s;
+ });
+
+ return { aliasSummaries, normalSummaries };
+ }
+
+ _getAlias(compatNode, terms) {
+ const targetNode = getCompatNode(compatNode, terms);
+ return targetNode ? targetNode._aliasOf : null;
+ }
+
+ /**
+ * Return the compatibility summary of the terms.
+ *
+ * @param {Array} browsers
+ * All browsers that to check
+ * e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
+ * @param {Array} database
+ * MDN compatibility dataset where finds from
+ * @param {Array} terms
+ * The terms which is checked the compatibility summary from the
+ * database. The paremeters are passed as `rest parameters`.
+ * e.g. _getCompatSummary(browsers, database, "user-select", ...)
+ * @return {Object}
+ * {
+ * database: The passed database as a parameter,
+ * terms: The passed terms as a parameter,
+ * url: The link which indicates the spec in MDN,
+ * deprecated: true if the spec of terms is deprecated,
+ * experimental: true if the spec of terms is experimental,
+ * unsupportedBrowsers: Array of unsupported browsers,
+ * }
+ */
+ _getCompatSummary(browsers, database, terms) {
+ const compatTable = getCompatTable(database, terms);
+
+ if (!compatTable) {
+ return { invalid: true, unsupportedBrowsers: [] };
+ }
+
+ const unsupportedBrowsers = [];
+ const prefixNeededBrowsers = [];
+
+ for (const browser of browsers) {
+ const state = this._getSupportState(
+ compatTable,
+ browser,
+ database,
+ terms
+ );
+
+ switch (state) {
+ case _SUPPORT_STATE_UNSUPPORTED_PREFIX_NEEDED: {
+ prefixNeededBrowsers.push(browser);
+ unsupportedBrowsers.push(browser);
+ break;
+ }
+ case _SUPPORT_STATE_UNSUPPORTED: {
+ unsupportedBrowsers.push(browser);
+ break;
+ }
+ }
+ }
+
+ const { deprecated, experimental } = compatTable.status || {};
+
+ return {
+ database,
+ terms,
+ url: compatTable.mdn_url,
+ specUrl: compatTable.spec_url,
+ deprecated,
+ experimental,
+ unsupportedBrowsers,
+ prefixNeededBrowsers,
+ };
+ }
+
+ /**
+ * Return the compatibility summary of the CSS property.
+ * This function just adds `property` filed to the result of `_getCompatSummary`.
+ *
+ * @param {Array} browsers
+ * All browsers that to check
+ * e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
+ * @return {Object} compatibility summary
+ */
+ _getCSSPropertyCompatSummary(browsers, property) {
+ const summary = this._getCompatSummary(
+ browsers,
+ this._cssPropertiesCompatData,
+ [property]
+ );
+ return Object.assign(summary, { property });
+ }
+
+ _getSupportState(compatTable, browser, compatNode, terms) {
+ const supportList = compatTable.support[browser.id];
+ if (!supportList) {
+ return _SUPPORT_STATE_BROWSER_NOT_FOUND;
+ }
+
+ const version = parseFloat(browser.version);
+ const terminal = terms.at(-1);
+ const prefix = terminal.match(PREFIX_REGEX)?.[0];
+
+ let prefixNeeded = false;
+ for (const support of supportList) {
+ const { alternative_name: alternativeName, added, removed } = support;
+
+ if (
+ // added id true when feature is supported, but we don't know the version
+ (added === true ||
+ // `null` and `undefined` is when we don't know if it's supported.
+ // Since we don't want to have false negative, we consider it as supported
+ added === null ||
+ added === undefined ||
+ // It was added on a previous version number
+ added <= version) &&
+ // `added` is false when the property isn't supported
+ added !== false &&
+ // `removed` is false when the feature wasn't removevd
+ (removed === false ||
+ // `null` and `undefined` is when we don't know if it was removed.
+ // Since we don't want to have false negative, we consider it as supported
+ removed === null ||
+ removed === undefined ||
+ // It was removed, but on a later version, so it's still supported
+ version <= removed)
+ ) {
+ if (alternativeName) {
+ if (alternativeName === terminal) {
+ return _SUPPORT_STATE_SUPPORTED;
+ }
+ } else if (
+ support.prefix === prefix ||
+ // There are compat data that are defined with prefix like "-moz-binding".
+ // In this case, we don't have to check the prefix.
+ (prefix && !this._getAlias(compatNode, terms))
+ ) {
+ return _SUPPORT_STATE_SUPPORTED;
+ }
+
+ prefixNeeded = true;
+ }
+ }
+
+ return prefixNeeded
+ ? _SUPPORT_STATE_UNSUPPORTED_PREFIX_NEEDED
+ : _SUPPORT_STATE_UNSUPPORTED;
+ }
+
+ _hasIssue({ unsupportedBrowsers, deprecated, experimental, invalid }) {
+ // Don't apply as issue the invalid term which was not in the database.
+ return (
+ !invalid && (unsupportedBrowsers.length || deprecated || experimental)
+ );
+ }
+
+ _toIssue(summary, type) {
+ const issue = Object.assign({}, summary, { type });
+ delete issue.database;
+ delete issue.terms;
+ delete issue.prefixNeededBrowsers;
+ return issue;
+ }
+
+ _toCSSIssues(summaries) {
+ const issues = [];
+
+ for (const summary of summaries) {
+ if (!this._hasIssue(summary)) {
+ continue;
+ }
+
+ const type = summary.aliases
+ ? COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY_ALIASES
+ : COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY;
+ issues.push(this._toIssue(summary, type));
+ }
+
+ return issues;
+ }
+}
+
+module.exports = MDNCompatibility;
diff --git a/devtools/server/actors/compatibility/lib/moz.build b/devtools/server/actors/compatibility/lib/moz.build
new file mode 100644
index 0000000000..a2fe36da6d
--- /dev/null
+++ b/devtools/server/actors/compatibility/lib/moz.build
@@ -0,0 +1,11 @@
+# -*- 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"]
+
+DevToolsModules(
+ "MDNCompatibility.js",
+)
diff --git a/devtools/server/actors/compatibility/lib/test/xpcshell/.eslintrc.js b/devtools/server/actors/compatibility/lib/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..65efbdee13
--- /dev/null
+++ b/devtools/server/actors/compatibility/lib/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/server/actors/compatibility/lib/test/xpcshell/head.js b/devtools/server/actors/compatibility/lib/test/xpcshell/head.js
new file mode 100644
index 0000000000..733c0400da
--- /dev/null
+++ b/devtools/server/actors/compatibility/lib/test/xpcshell/head.js
@@ -0,0 +1,10 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+
+const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+);
diff --git a/devtools/server/actors/compatibility/lib/test/xpcshell/test_mdn-compatibility.js b/devtools/server/actors/compatibility/lib/test/xpcshell/test_mdn-compatibility.js
new file mode 100644
index 0000000000..e411feb3b0
--- /dev/null
+++ b/devtools/server/actors/compatibility/lib/test/xpcshell/test_mdn-compatibility.js
@@ -0,0 +1,193 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test for the MDN compatibility diagnosis module.
+
+const {
+ COMPATIBILITY_ISSUE_TYPE,
+} = require("resource://devtools/shared/constants.js");
+const MDNCompatibility = require("resource://devtools/server/actors/compatibility/lib/MDNCompatibility.js");
+const cssPropertiesCompatData = require("resource://devtools/shared/compatibility/dataset/css-properties.json");
+
+const mdnCompatibility = new MDNCompatibility(cssPropertiesCompatData);
+
+const FIREFOX_1 = {
+ id: "firefox",
+ version: "1",
+};
+
+const FIREFOX_60 = {
+ id: "firefox",
+ version: "60",
+};
+
+const FIREFOX_69 = {
+ id: "firefox",
+ version: "69",
+};
+
+const FIREFOX_ANDROID_1 = {
+ id: "firefox_android",
+ version: "1",
+};
+
+const SAFARI_13 = {
+ id: "safari",
+ version: "13",
+};
+
+const TEST_DATA = [
+ {
+ description: "Test for a supported property",
+ declarations: [{ name: "background-color" }],
+ browsers: [FIREFOX_69],
+ expectedIssues: [],
+ },
+ {
+ description: "Test for some supported properties",
+ declarations: [{ name: "background-color" }, { name: "color" }],
+ browsers: [FIREFOX_69],
+ expectedIssues: [],
+ },
+ {
+ description: "Test for an unsupported property",
+ declarations: [{ name: "grid-column" }],
+ browsers: [FIREFOX_1],
+ expectedIssues: [
+ {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY,
+ property: "grid-column",
+ url: "https://developer.mozilla.org/docs/Web/CSS/grid-column",
+ specUrl: "https://drafts.csswg.org/css-grid/#placement-shorthands",
+ deprecated: false,
+ experimental: false,
+ unsupportedBrowsers: [FIREFOX_1],
+ },
+ ],
+ },
+ {
+ description: "Test for an unknown property",
+ declarations: [{ name: "unknown-property" }],
+ browsers: [FIREFOX_69],
+ expectedIssues: [],
+ },
+ {
+ description: "Test for a deprecated property",
+ declarations: [{ name: "clip" }],
+ browsers: [FIREFOX_69],
+ expectedIssues: [
+ {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY,
+ property: "clip",
+ url: "https://developer.mozilla.org/docs/Web/CSS/clip",
+ specUrl: "https://drafts.fxtf.org/css-masking/#clip-property",
+ deprecated: true,
+ experimental: false,
+ unsupportedBrowsers: [],
+ },
+ ],
+ },
+ {
+ description: "Test for a property having some issues",
+ declarations: [{ name: "ruby-align" }],
+ browsers: [FIREFOX_1],
+ expectedIssues: [
+ {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY,
+ property: "ruby-align",
+ url: "https://developer.mozilla.org/docs/Web/CSS/ruby-align",
+ specUrl: "https://drafts.csswg.org/css-ruby/#ruby-align-property",
+ deprecated: false,
+ experimental: true,
+ unsupportedBrowsers: [FIREFOX_1],
+ },
+ ],
+ },
+ {
+ description:
+ "Test for an aliased property not supported in all browsers with prefix needed",
+ declarations: [{ name: "-moz-user-select" }],
+ browsers: [FIREFOX_69, SAFARI_13],
+ expectedIssues: [
+ {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY_ALIASES,
+ property: "user-select",
+ aliases: ["-moz-user-select"],
+ url: "https://developer.mozilla.org/docs/Web/CSS/user-select",
+ specUrl: "https://drafts.csswg.org/css-ui/#content-selection",
+ deprecated: false,
+ experimental: false,
+ prefixNeeded: true,
+ unsupportedBrowsers: [SAFARI_13],
+ },
+ ],
+ },
+ {
+ description:
+ "Test for an aliased property not supported in all browsers without prefix needed",
+ declarations: [
+ { name: "-moz-user-select" },
+ { name: "-webkit-user-select" },
+ ],
+ browsers: [FIREFOX_ANDROID_1, FIREFOX_69, SAFARI_13],
+ expectedIssues: [
+ {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY_ALIASES,
+ property: "user-select",
+ aliases: ["-moz-user-select", "-webkit-user-select"],
+ url: "https://developer.mozilla.org/docs/Web/CSS/user-select",
+ specUrl: "https://drafts.csswg.org/css-ui/#content-selection",
+ deprecated: false,
+ experimental: false,
+ prefixNeeded: false,
+ unsupportedBrowsers: [FIREFOX_ANDROID_1],
+ },
+ ],
+ },
+ {
+ description: "Test for aliased properties supported in all browsers",
+ declarations: [
+ { name: "-moz-user-select" },
+ { name: "-webkit-user-select" },
+ ],
+ browsers: [FIREFOX_69, SAFARI_13],
+ expectedIssues: [],
+ },
+ {
+ description: "Test for a property defined with prefix",
+ declarations: [{ name: "-moz-user-input" }],
+ browsers: [FIREFOX_1, FIREFOX_60, FIREFOX_69],
+ expectedIssues: [
+ {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY,
+ property: "-moz-user-input",
+ url: "https://developer.mozilla.org/docs/Web/CSS/-moz-user-input",
+ specUrl: undefined,
+ deprecated: true,
+ experimental: false,
+ unsupportedBrowsers: [],
+ },
+ ],
+ },
+];
+
+add_task(() => {
+ for (const {
+ description,
+ declarations,
+ browsers,
+ expectedIssues,
+ } of TEST_DATA) {
+ info(description);
+ const issues = mdnCompatibility.getCSSDeclarationBlockIssues(
+ declarations,
+ browsers
+ );
+ deepEqual(
+ issues,
+ expectedIssues,
+ "CSS declaration compatibility data matches expectations"
+ );
+ }
+});
diff --git a/devtools/server/actors/compatibility/lib/test/xpcshell/xpcshell.toml b/devtools/server/actors/compatibility/lib/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..c0bdf3f121
--- /dev/null
+++ b/devtools/server/actors/compatibility/lib/test/xpcshell/xpcshell.toml
@@ -0,0 +1,7 @@
+[DEFAULT]
+tags = "devtools"
+head = "head.js"
+firefox-appdir = "browser"
+skip-if = ["os == 'android'"]
+
+["test_mdn-compatibility.js"]
diff --git a/devtools/server/actors/compatibility/moz.build b/devtools/server/actors/compatibility/moz.build
new file mode 100644
index 0000000000..010b027d37
--- /dev/null
+++ b/devtools/server/actors/compatibility/moz.build
@@ -0,0 +1,16 @@
+# -*- 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 += [
+ "lib",
+]
+
+DevToolsModules(
+ "compatibility.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Inspector: Compatibility")
diff --git a/devtools/server/actors/css-properties.js b/devtools/server/actors/css-properties.js
new file mode 100644
index 0000000000..2b2633ae16
--- /dev/null
+++ b/devtools/server/actors/css-properties.js
@@ -0,0 +1,105 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ cssPropertiesSpec,
+} = require("resource://devtools/shared/specs/css-properties.js");
+
+loader.lazyRequireGetter(
+ this,
+ "CSS_TYPES",
+ "resource://devtools/shared/css/constants.js",
+ true
+);
+
+class CssPropertiesActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, cssPropertiesSpec);
+ this.targetActor = targetActor;
+ }
+
+ getCSSDatabase() {
+ const properties = generateCssProperties(this.targetActor.window.document);
+
+ return { properties };
+ }
+}
+exports.CssPropertiesActor = CssPropertiesActor;
+
+/**
+ * Generate the CSS properties object. Every key is the property name, while
+ * the values are objects that contain information about that property.
+ *
+ * @param {Document} doc
+ * @return {Object}
+ */
+function generateCssProperties(doc) {
+ const properties = {};
+ const propertyNames = InspectorUtils.getCSSPropertyNames({
+ includeAliases: true,
+ });
+
+ for (const name of propertyNames) {
+ // Get the list of CSS types this property supports.
+ const supports = [];
+ for (const type in CSS_TYPES) {
+ if (safeCssPropertySupportsType(name, type)) {
+ supports.push(type);
+ }
+ }
+
+ const values = InspectorUtils.getCSSValuesForProperty(name);
+ const subproperties = InspectorUtils.getSubpropertiesForCSSProperty(name);
+
+ properties[name] = {
+ isInherited: InspectorUtils.isInheritedProperty(doc, name),
+ values,
+ supports,
+ subproperties,
+ };
+ }
+
+ return properties;
+}
+exports.generateCssProperties = generateCssProperties;
+
+/**
+ * Test if a CSS is property is known using server-code.
+ *
+ * @param {string} name
+ * @return {Boolean}
+ */
+function isCssPropertyKnown(name) {
+ try {
+ // If the property name is unknown, the cssPropertyIsShorthand
+ // will throw an exception. But if it is known, no exception will
+ // be thrown; so we just ignore the return value.
+ InspectorUtils.cssPropertyIsShorthand(name);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+exports.isCssPropertyKnown = isCssPropertyKnown;
+
+/**
+ * A wrapper for InspectorUtils.cssPropertySupportsType that ignores invalid
+ * properties.
+ *
+ * @param {String} name The property name.
+ * @param {number} type The type tested for support.
+ * @return {Boolean} Whether the property supports the type.
+ * If the property is unknown, false is returned.
+ */
+function safeCssPropertySupportsType(name, type) {
+ try {
+ return InspectorUtils.cssPropertySupportsType(name, type);
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/devtools/server/actors/descriptors/moz.build b/devtools/server/actors/descriptors/moz.build
new file mode 100644
index 0000000000..bf297b3dcb
--- /dev/null
+++ b/devtools/server/actors/descriptors/moz.build
@@ -0,0 +1,12 @@
+# -*- 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(
+ "process.js",
+ "tab.js",
+ "webextension.js",
+ "worker.js",
+)
diff --git a/devtools/server/actors/descriptors/process.js b/devtools/server/actors/descriptors/process.js
new file mode 100644
index 0000000000..19944c7d03
--- /dev/null
+++ b/devtools/server/actors/descriptors/process.js
@@ -0,0 +1,246 @@
+/* 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";
+
+/*
+ * Represents any process running in Firefox.
+ * This can be:
+ * - the parent process, where all top level chrome window runs:
+ * like browser.xhtml, sidebars, devtools iframes, the browser console, ...
+ * - any content process
+ *
+ * There is some special cases in the class around:
+ * - xpcshell, where there is only one process which doesn't expose any DOM document
+ * And instead of exposing a ParentProcessTargetActor, getTarget will return
+ * a ContentProcessTargetActor.
+ * - background task, similarly to xpcshell, they don't expose any DOM document
+ * and this also works with a ContentProcessTargetActor.
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ processDescriptorSpec,
+} = require("resource://devtools/shared/specs/descriptors/process.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+const {
+ createBrowserSessionContext,
+ createContentProcessSessionContext,
+} = require("resource://devtools/server/actors/watcher/session-context.js");
+
+loader.lazyRequireGetter(
+ this,
+ "ContentProcessTargetActor",
+ "resource://devtools/server/actors/targets/content-process.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ParentProcessTargetActor",
+ "resource://devtools/server/actors/targets/parent-process.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "connectToContentProcess",
+ "resource://devtools/server/connectors/content-process-connector.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WatcherActor",
+ "resource://devtools/server/actors/watcher.js",
+ true
+);
+
+class ProcessDescriptorActor extends Actor {
+ constructor(connection, options = {}) {
+ super(connection, processDescriptorSpec);
+
+ if ("id" in options && typeof options.id != "number") {
+ throw Error("process connect requires a valid `id` attribute.");
+ }
+
+ this.id = options.id;
+ this._windowGlobalTargetActor = null;
+ this.isParent = options.parent;
+ this.destroy = this.destroy.bind(this);
+ }
+
+ get browsingContextID() {
+ if (this._windowGlobalTargetActor) {
+ return this._windowGlobalTargetActor.docShell.browsingContext.id;
+ }
+ return null;
+ }
+
+ get isWindowlessParent() {
+ return this.isParent && (this.isXpcshell || this.isBackgroundTaskMode);
+ }
+
+ get isXpcshell() {
+ return Services.env.exists("XPCSHELL_TEST_PROFILE_DIR");
+ }
+
+ get isBackgroundTaskMode() {
+ const bts = Cc["@mozilla.org/backgroundtasks;1"]?.getService(
+ Ci.nsIBackgroundTasks
+ );
+ return bts && bts.isBackgroundTaskMode;
+ }
+
+ _parentProcessConnect() {
+ let targetActor;
+ if (this.isWindowlessParent) {
+ // Check if we are running on xpcshell or in background task mode.
+ // In these modes, there is no valid browsing context to attach to
+ // and so ParentProcessTargetActor doesn't make sense as it inherits from
+ // WindowGlobalTargetActor. So instead use ContentProcessTargetActor, which
+ // matches the needs of these modes.
+ targetActor = new ContentProcessTargetActor(this.conn, {
+ isXpcShellTarget: true,
+ sessionContext: createContentProcessSessionContext(),
+ });
+ } else {
+ // Create the target actor for the parent process, which is in the same process
+ // as this target. Because we are in the same process, we have a true actor that
+ // should be managed by the ProcessDescriptorActor.
+ targetActor = new ParentProcessTargetActor(this.conn, {
+ // This target actor is special and will stay alive as long
+ // as the toolbox/client is alive. It is the original top level target for
+ // the BrowserToolbox and isTopLevelTarget should always be true here.
+ // (It isn't the typical behavior of WindowGlobalTargetActor's base class)
+ isTopLevelTarget: true,
+ sessionContext: createBrowserSessionContext(),
+ });
+ // this is a special field that only parent process with a browsing context
+ // have, as they are the only processes at the moment that have child
+ // browsing contexts
+ this._windowGlobalTargetActor = targetActor;
+ }
+ this.manage(targetActor);
+ // to be consistent with the return value of the _childProcessConnect, we are returning
+ // the form here. This might be memoized in the future
+ return targetActor.form();
+ }
+
+ /**
+ * Connect to a remote process actor, always a ContentProcess target.
+ */
+ async _childProcessConnect() {
+ const { id } = this;
+ const mm = this._lookupMessageManager(id);
+ if (!mm) {
+ return {
+ error: "noProcess",
+ message: "There is no process with id '" + id + "'.",
+ };
+ }
+ const childTargetForm = await connectToContentProcess(
+ this.conn,
+ mm,
+ this.destroy
+ );
+ return childTargetForm;
+ }
+
+ _lookupMessageManager(id) {
+ for (let i = 0; i < Services.ppmm.childCount; i++) {
+ const mm = Services.ppmm.getChildAt(i);
+
+ // A zero id is used for the parent process, instead of its actual pid.
+ if (id ? mm.osPid == id : mm.isInProcess) {
+ return mm;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Connect the a process actor.
+ */
+ async getTarget() {
+ if (!DevToolsServer.allowChromeProcess) {
+ return {
+ error: "forbidden",
+ message: "You are not allowed to debug processes.",
+ };
+ }
+ if (this.isParent) {
+ return this._parentProcessConnect();
+ }
+ // This is a remote process we are connecting to
+ return this._childProcessConnect();
+ }
+
+ /**
+ * Return a Watcher actor, allowing to keep track of targets which
+ * already exists or will be created. It also helps knowing when they
+ * are destroyed.
+ */
+ getWatcher() {
+ if (!this.watcher) {
+ this.watcher = new WatcherActor(this.conn, createBrowserSessionContext());
+ this.manage(this.watcher);
+ }
+ return this.watcher;
+ }
+
+ form() {
+ return {
+ actor: this.actorID,
+ id: this.id,
+ isParent: this.isParent,
+ isWindowlessParent: this.isWindowlessParent,
+ traits: {
+ // Supports the Watcher actor. Can be removed as part of Bug 1680280.
+ // Bug 1687461: WatcherActor only supports the parent process, where we debug everything.
+ // For the "Browser Content Toolbox", where we debug only one content process,
+ // we will still be using legacy listeners.
+ watcher: this.isParent,
+ // ParentProcessTargetActor can be reloaded.
+ supportsReloadDescriptor: this.isParent && !this.isWindowlessParent,
+ },
+ };
+ }
+
+ async reloadDescriptor() {
+ if (!this.isParent || this.isWindowlessParent) {
+ throw new Error(
+ "reloadDescriptor is only available for parent process descriptors"
+ );
+ }
+
+ // Reload for the parent process will restart the whole browser
+ //
+ // This aims at replicate `DevelopmentHelpers.quickRestart`
+ // This allows a user to do a full firefox restart + session restore
+ // Via Ctrl+Alt+R on the Browser Console/Toolbox
+
+ // Maximize the chance of fetching new source content by clearing the cache
+ Services.obs.notifyObservers(null, "startupcache-invalidate");
+
+ // Avoid safemode popup from appearing on restart
+ Services.env.set("MOZ_DISABLE_SAFE_MODE_KEY", "1");
+
+ Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ );
+ }
+
+ destroy() {
+ this.emit("descriptor-destroyed");
+
+ this._windowGlobalTargetActor = null;
+ super.destroy();
+ }
+}
+
+exports.ProcessDescriptorActor = ProcessDescriptorActor;
diff --git a/devtools/server/actors/descriptors/tab.js b/devtools/server/actors/descriptors/tab.js
new file mode 100644
index 0000000000..ea20d3fb36
--- /dev/null
+++ b/devtools/server/actors/descriptors/tab.js
@@ -0,0 +1,253 @@
+/* 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";
+
+/*
+ * Descriptor Actor that represents a Tab in the parent process. It
+ * launches a WindowGlobalTargetActor in the content process to do the real work and tunnels the
+ * data.
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ tabDescriptorSpec,
+} = require("resource://devtools/shared/specs/descriptors/tab.js");
+
+const {
+ connectToFrame,
+} = require("resource://devtools/server/connectors/frame-connector.js");
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const {
+ createBrowserElementSessionContext,
+} = require("resource://devtools/server/actors/watcher/session-context.js");
+
+loader.lazyRequireGetter(
+ this,
+ "WatcherActor",
+ "resource://devtools/server/actors/watcher.js",
+ true
+);
+
+/**
+ * Creates a target actor proxy for handling requests to a single browser frame.
+ * Both <xul:browser> and <iframe mozbrowser> are supported.
+ * This actor is a shim that connects to a WindowGlobalTargetActor in a remote browser process.
+ * All RDP packets get forwarded using the message manager.
+ *
+ * @param connection The main RDP connection.
+ * @param browser <xul:browser> or <iframe mozbrowser> element to connect to.
+ */
+class TabDescriptorActor extends Actor {
+ constructor(connection, browser) {
+ super(connection, tabDescriptorSpec);
+ this._browser = browser;
+ }
+
+ form() {
+ const form = {
+ actor: this.actorID,
+ browserId: this._browser.browserId,
+ browsingContextID:
+ this._browser && this._browser.browsingContext
+ ? this._browser.browsingContext.id
+ : null,
+ isZombieTab: this._isZombieTab(),
+ outerWindowID: this._getOuterWindowId(),
+ selected: this.selected,
+ title: this._getTitle(),
+ traits: {
+ // Supports the Watcher actor. Can be removed as part of Bug 1680280.
+ watcher: true,
+ supportsReloadDescriptor: true,
+ },
+ url: this._getUrl(),
+ };
+
+ return form;
+ }
+
+ _getTitle() {
+ // If the content already provides a title, use it.
+ if (this._browser.contentTitle) {
+ return this._browser.contentTitle;
+ }
+
+ // For zombie or lazy tabs (tab created, but content has not been loaded),
+ // try to retrieve the title from the XUL Tab itself.
+ // Note: this only works on Firefox desktop.
+ if (this._tabbrowser) {
+ const tab = this._tabbrowser.getTabForBrowser(this._browser);
+ if (tab) {
+ return tab.label;
+ }
+ }
+
+ // No title available.
+ return null;
+ }
+
+ _getUrl() {
+ if (!this._browser || !this._browser.browsingContext) {
+ return "";
+ }
+
+ const { browsingContext } = this._browser;
+ return browsingContext.currentWindowGlobal.documentURI.spec;
+ }
+
+ _getOuterWindowId() {
+ if (!this._browser || !this._browser.browsingContext) {
+ return "";
+ }
+
+ const { browsingContext } = this._browser;
+ return browsingContext.currentWindowGlobal.outerWindowId;
+ }
+
+ get selected() {
+ // getMostRecentBrowserWindow will find the appropriate window on Firefox
+ // Desktop and on GeckoView.
+ const topAppWindow = Services.wm.getMostRecentBrowserWindow();
+
+ const selectedBrowser = topAppWindow?.gBrowser?.selectedBrowser;
+ if (!selectedBrowser) {
+ // Note: gBrowser is not available on GeckoView.
+ // We should find another way to know if this browser is the selected
+ // browser. See Bug 1631020.
+ return false;
+ }
+
+ return this._browser === selectedBrowser;
+ }
+
+ async getTarget() {
+ if (!this.conn) {
+ return {
+ error: "tabDestroyed",
+ message: "Tab destroyed while performing a TabDescriptorActor update",
+ };
+ }
+
+ /* eslint-disable-next-line no-async-promise-executor */
+ return new Promise(async (resolve, reject) => {
+ const onDestroy = () => {
+ // Reject the update promise if the tab was destroyed while requesting an update
+ reject({
+ error: "tabDestroyed",
+ message: "Tab destroyed while performing a TabDescriptorActor update",
+ });
+
+ // Targets created from the TabDescriptor are not created via JSWindowActors and
+ // we need to notify the watcher manually about their destruction.
+ // TabDescriptor's targets are created via TabDescriptor.getTarget and are still using
+ // message manager instead of JSWindowActors.
+ if (this.watcher && this.targetActorForm) {
+ this.watcher.notifyTargetDestroyed(this.targetActorForm);
+ }
+ };
+
+ try {
+ // Check if the browser is still connected before calling connectToFrame
+ if (!this._browser.isConnected) {
+ onDestroy();
+ return;
+ }
+
+ const connectForm = await connectToFrame(
+ this.conn,
+ this._browser,
+ onDestroy
+ );
+ this.targetActorForm = connectForm;
+ resolve(connectForm);
+ } catch (e) {
+ reject({
+ error: "tabDestroyed",
+ message: "Tab destroyed while connecting to the frame",
+ });
+ }
+ });
+ }
+
+ /**
+ * Return a Watcher actor, allowing to keep track of targets which
+ * already exists or will be created. It also helps knowing when they
+ * are destroyed.
+ */
+ getWatcher(config) {
+ if (!this.watcher) {
+ this.watcher = new WatcherActor(
+ this.conn,
+ createBrowserElementSessionContext(this._browser, {
+ isServerTargetSwitchingEnabled: config.isServerTargetSwitchingEnabled,
+ isPopupDebuggingEnabled: config.isPopupDebuggingEnabled,
+ })
+ );
+ this.manage(this.watcher);
+ }
+ return this.watcher;
+ }
+
+ get _tabbrowser() {
+ if (this._browser && typeof this._browser.getTabBrowser == "function") {
+ return this._browser.getTabBrowser();
+ }
+ return null;
+ }
+
+ async getFavicon() {
+ if (!AppConstants.MOZ_PLACES) {
+ // PlacesUtils is not supported
+ return null;
+ }
+
+ try {
+ const { data } = await lazy.PlacesUtils.promiseFaviconData(
+ this._getUrl()
+ );
+ return data;
+ } catch (e) {
+ // Favicon unavailable for this url.
+ return null;
+ }
+ }
+
+ _isZombieTab() {
+ // Note: GeckoView doesn't support zombie tabs
+ const tabbrowser = this._tabbrowser;
+ const tab = tabbrowser ? tabbrowser.getTabForBrowser(this._browser) : null;
+ return tab?.hasAttribute && tab.hasAttribute("pending");
+ }
+
+ reloadDescriptor({ bypassCache }) {
+ if (!this._browser || !this._browser.browsingContext) {
+ return;
+ }
+
+ this._browser.browsingContext.reload(
+ bypassCache
+ ? Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE
+ : Ci.nsIWebNavigation.LOAD_FLAGS_NONE
+ );
+ }
+
+ destroy() {
+ this.emit("descriptor-destroyed");
+ this._browser = null;
+
+ super.destroy();
+ }
+}
+
+exports.TabDescriptorActor = TabDescriptorActor;
diff --git a/devtools/server/actors/descriptors/webextension.js b/devtools/server/actors/descriptors/webextension.js
new file mode 100644
index 0000000000..56e4abfc41
--- /dev/null
+++ b/devtools/server/actors/descriptors/webextension.js
@@ -0,0 +1,336 @@
+/* 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";
+
+/*
+ * Represents a WebExtension add-on in the parent process. This gives some metadata about
+ * the add-on and watches for uninstall events. This uses a proxy to access the
+ * WebExtension in the WebExtension process via the message manager.
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ webExtensionDescriptorSpec,
+} = require("resource://devtools/shared/specs/descriptors/webextension.js");
+
+const {
+ connectToFrame,
+} = require("resource://devtools/server/connectors/frame-connector.js");
+const {
+ createWebExtensionSessionContext,
+} = require("resource://devtools/server/actors/watcher/session-context.js");
+
+const lazy = {};
+loader.lazyGetter(lazy, "AddonManager", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).AddonManager;
+});
+loader.lazyGetter(lazy, "ExtensionParent", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).ExtensionParent;
+});
+loader.lazyRequireGetter(
+ this,
+ "WatcherActor",
+ "resource://devtools/server/actors/watcher.js",
+ true
+);
+
+const BGSCRIPT_STATUSES = {
+ RUNNING: "RUNNING",
+ STOPPED: "STOPPED",
+};
+
+/**
+ * Creates the actor that represents the addon in the parent process, which connects
+ * itself to a WebExtensionTargetActor counterpart which is created in the extension
+ * process (or in the main process if the WebExtensions OOP mode is disabled).
+ *
+ * The WebExtensionDescriptorActor subscribes itself as an AddonListener on the AddonManager
+ * and forwards this events to child actor (e.g. on addon reload or when the addon is
+ * uninstalled completely) and connects to the child extension process using a `browser`
+ * element provided by the extension internals (it is not related to any single extension,
+ * but it will be created automatically to the currently selected "WebExtensions OOP mode"
+ * and it persist across the extension reloads (it is destroyed once the actor exits).
+ * WebExtensionDescriptorActor is a child of RootActor, it can be retrieved via
+ * RootActor.listAddons request.
+ *
+ * @param {DevToolsServerConnection} conn
+ * The connection to the client.
+ * @param {AddonWrapper} addon
+ * The target addon.
+ */
+class WebExtensionDescriptorActor extends Actor {
+ constructor(conn, addon) {
+ super(conn, webExtensionDescriptorSpec);
+ this.addon = addon;
+ this.addonId = addon.id;
+ this._childFormPromise = null;
+
+ this._onChildExit = this._onChildExit.bind(this);
+ this.destroy = this.destroy.bind(this);
+ lazy.AddonManager.addAddonListener(this);
+ }
+
+ form() {
+ const { addonId } = this;
+ const policy = lazy.ExtensionParent.WebExtensionPolicy.getByID(addonId);
+ const persistentBackgroundScript =
+ lazy.ExtensionParent.DebugUtils.hasPersistentBackgroundScript(addonId);
+ const backgroundScriptStatus = this._getBackgroundScriptStatus();
+
+ return {
+ actor: this.actorID,
+ backgroundScriptStatus,
+ // Note that until the policy becomes active,
+ // getTarget/connectToFrame will fail attaching to the web extension:
+ // https://searchfox.org/mozilla-central/rev/526a5089c61db85d4d43eb0e46edaf1f632e853a/toolkit/components/extensions/WebExtensionPolicy.cpp#551-553
+ debuggable: policy?.active && this.addon.isDebuggable,
+ hidden: this.addon.hidden,
+ // iconDataURL is available after calling loadIconDataURL
+ iconDataURL: this._iconDataURL,
+ iconURL: this.addon.iconURL,
+ id: addonId,
+ isSystem: this.addon.isSystem,
+ isWebExtension: this.addon.isWebExtension,
+ manifestURL: policy && policy.getURL("manifest.json"),
+ name: this.addon.name,
+ persistentBackgroundScript,
+ temporarilyInstalled: this.addon.temporarilyInstalled,
+ traits: {
+ supportsReloadDescriptor: true,
+ // Supports the Watcher actor. Can be removed as part of Bug 1680280.
+ watcher: true,
+ },
+ url: this.addon.sourceURI ? this.addon.sourceURI.spec : undefined,
+ warnings: lazy.ExtensionParent.DebugUtils.getExtensionManifestWarnings(
+ this.addonId
+ ),
+ };
+ }
+
+ /**
+ * Return a Watcher actor, allowing to keep track of targets which
+ * already exists or will be created. It also helps knowing when they
+ * are destroyed.
+ */
+ async getWatcher(config = {}) {
+ if (!this.watcher) {
+ // Ensure connecting to the webextension frame in order to populate this._form
+ await this._extensionFrameConnect();
+ this.watcher = new WatcherActor(
+ this.conn,
+ createWebExtensionSessionContext(
+ {
+ addonId: this.addonId,
+ browsingContextID: this._form.browsingContextID,
+ innerWindowId: this._form.innerWindowId,
+ },
+ config
+ )
+ );
+ this.manage(this.watcher);
+ }
+ return this.watcher;
+ }
+
+ async getTarget() {
+ const form = await this._extensionFrameConnect();
+ // Merge into the child actor form, some addon metadata
+ // (e.g. the addon name shown in the addon debugger window title).
+ return Object.assign(form, {
+ iconURL: this.addon.iconURL,
+ id: this.addon.id,
+ name: this.addon.name,
+ });
+ }
+
+ getChildren() {
+ return [];
+ }
+
+ async _extensionFrameConnect() {
+ if (this._form) {
+ return this._form;
+ }
+
+ this._browser =
+ await lazy.ExtensionParent.DebugUtils.getExtensionProcessBrowser(this);
+
+ const policy = lazy.ExtensionParent.WebExtensionPolicy.getByID(
+ this.addonId
+ );
+ this._form = await connectToFrame(this.conn, this._browser, this.destroy, {
+ addonId: this.addonId,
+ addonBrowsingContextGroupId: policy.browsingContextGroupId,
+ // Bug 1754452: This flag is passed by the client to getWatcher(), but the server
+ // doesn't support this anyway. So always pass false here and keep things simple.
+ // Once we enable this flag, we will stop using connectToFrame and instantiate
+ // the WebExtensionTargetActor from watcher code instead, so that shouldn't
+ // introduce an issue for the future.
+ isServerTargetSwitchingEnabled: false,
+ });
+
+ // connectToFrame may resolve to a null form,
+ // in case the browser element is destroyed before it is fully connected to it.
+ if (!this._form) {
+ throw new Error(
+ "browser element destroyed while connecting to it: " + this.addon.name
+ );
+ }
+
+ this._childActorID = this._form.actor;
+
+ // Exit the proxy child actor if the child actor has been destroyed.
+ this._mm.addMessageListener("debug:webext_child_exit", this._onChildExit);
+
+ return this._form;
+ }
+
+ /**
+ * Note that reloadDescriptor is the common API name for descriptors
+ * which support to be reloaded, while WebExtensionDescriptorActor::reload
+ * is a legacy API which is for instance used from web-ext.
+ *
+ * bypassCache has no impact for addon reloads.
+ */
+ reloadDescriptor({ bypassCache }) {
+ return this.reload();
+ }
+
+ async reload() {
+ await this.addon.reload();
+ return {};
+ }
+
+ async terminateBackgroundScript() {
+ await lazy.ExtensionParent.DebugUtils.terminateBackgroundScript(
+ this.addonId
+ );
+ }
+
+ // This function will be called from RootActor in case that the devtools client
+ // retrieves list of addons with `iconDataURL` option.
+ async loadIconDataURL() {
+ this._iconDataURL = await this.getIconDataURL();
+ }
+
+ async getIconDataURL() {
+ if (!this.addon.iconURL) {
+ return null;
+ }
+
+ const xhr = new XMLHttpRequest();
+ xhr.responseType = "blob";
+ xhr.open("GET", this.addon.iconURL, true);
+
+ if (this.addon.iconURL.toLowerCase().endsWith(".svg")) {
+ // Maybe SVG, thus force to change mime type.
+ xhr.overrideMimeType("image/svg+xml");
+ }
+
+ try {
+ const blob = await new Promise((resolve, reject) => {
+ xhr.onload = () => resolve(xhr.response);
+ xhr.onerror = reject;
+ xhr.send();
+ });
+
+ const reader = new FileReader();
+ return await new Promise((resolve, reject) => {
+ reader.onloadend = () => resolve(reader.result);
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ } catch (_) {
+ console.warn(`Failed to create data url from [${this.addon.iconURL}]`);
+ return null;
+ }
+ }
+
+ // Private Methods
+ _getBackgroundScriptStatus() {
+ const isRunning = lazy.ExtensionParent.DebugUtils.isBackgroundScriptRunning(
+ this.addonId
+ );
+ // The background script status doesn't apply to this addon (e.g. the addon
+ // type doesn't have any code, like staticthemes/langpacks/dictionaries, or
+ // the extension does not have a background script at all).
+ if (isRunning === undefined) {
+ return undefined;
+ }
+
+ return isRunning ? BGSCRIPT_STATUSES.RUNNING : BGSCRIPT_STATUSES.STOPPED;
+ }
+
+ get _mm() {
+ return (
+ this._browser &&
+ (this._browser.messageManager || this._browser.frameLoader.messageManager)
+ );
+ }
+
+ /**
+ * Handle the child actor exit.
+ */
+ _onChildExit(msg) {
+ if (msg.json.actor !== this._childActorID) {
+ return;
+ }
+
+ this.destroy();
+ }
+
+ // AddonManagerListener callbacks.
+ onInstalled(addon) {
+ if (addon.id != this.addonId) {
+ return;
+ }
+
+ // Update the AddonManager's addon object on reload/update.
+ this.addon = addon;
+ }
+
+ onUninstalled(addon) {
+ if (addon != this.addon) {
+ return;
+ }
+
+ this.destroy();
+ }
+
+ destroy() {
+ lazy.AddonManager.removeAddonListener(this);
+
+ this.addon = null;
+ if (this._mm) {
+ this._mm.removeMessageListener(
+ "debug:webext_child_exit",
+ this._onChildExit
+ );
+
+ this._mm.sendAsyncMessage("debug:webext_parent_exit", {
+ actor: this._childActorID,
+ });
+
+ lazy.ExtensionParent.DebugUtils.releaseExtensionProcessBrowser(this);
+ }
+
+ this._browser = null;
+ this._childActorID = null;
+
+ this.emit("descriptor-destroyed");
+
+ super.destroy();
+ }
+}
+
+exports.WebExtensionDescriptorActor = WebExtensionDescriptorActor;
diff --git a/devtools/server/actors/descriptors/worker.js b/devtools/server/actors/descriptors/worker.js
new file mode 100644
index 0000000000..89ca918e05
--- /dev/null
+++ b/devtools/server/actors/descriptors/worker.js
@@ -0,0 +1,182 @@
+/* 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";
+
+/*
+ * Target actor for any of the various kinds of workers.
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ */
+
+// protocol.js uses objects as exceptions in order to define
+// error packets.
+/* eslint-disable no-throw-literal */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ workerDescriptorSpec,
+} = require("resource://devtools/shared/specs/descriptors/worker.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const {
+ createWorkerSessionContext,
+} = require("resource://devtools/server/actors/watcher/session-context.js");
+
+loader.lazyRequireGetter(
+ this,
+ "connectToWorker",
+ "resource://devtools/server/connectors/worker-connector.js",
+ true
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "swm",
+ "@mozilla.org/serviceworkers/manager;1",
+ "nsIServiceWorkerManager"
+);
+
+class WorkerDescriptorActor extends Actor {
+ constructor(conn, dbg) {
+ super(conn, workerDescriptorSpec);
+ this._dbg = dbg;
+
+ this._threadActor = null;
+ this._transport = null;
+
+ this._dbgListener = {
+ onClose: this._onWorkerClose.bind(this),
+ onError: this._onWorkerError.bind(this),
+ };
+
+ this._dbg.addListener(this._dbgListener);
+ this._attached = true;
+ }
+
+ form() {
+ const form = {
+ actor: this.actorID,
+
+ consoleActor: this._consoleActor,
+ threadActor: this._threadActor,
+ tracerActor: this._tracerActor,
+
+ id: this._dbg.id,
+ url: this._dbg.url,
+ traits: {},
+ type: this._dbg.type,
+ };
+ if (this._dbg.type === Ci.nsIWorkerDebugger.TYPE_SERVICE) {
+ /**
+ * The ServiceWorkerManager in content processes don't maintain
+ * ServiceWorkerRegistrations; record the ServiceWorker's ID, and
+ * this data will be merged with the corresponding registration in
+ * the parent process.
+ */
+ if (!DevToolsServer.isInChildProcess) {
+ const registration = this._getServiceWorkerRegistrationInfo();
+ form.scope = registration.scope;
+ const newestWorker =
+ registration.activeWorker ||
+ registration.waitingWorker ||
+ registration.installingWorker;
+ form.fetch = newestWorker?.handlesFetchEvents;
+ }
+ }
+ return form;
+ }
+
+ detach() {
+ if (!this._attached) {
+ throw { error: "wrongState" };
+ }
+
+ this.destroy();
+ }
+
+ destroy() {
+ if (this._attached) {
+ this._detach();
+ }
+
+ this.emit("descriptor-destroyed");
+ super.destroy();
+ }
+
+ async getTarget() {
+ if (!this._attached) {
+ return { error: "wrongState" };
+ }
+
+ if (this._threadActor !== null) {
+ return {
+ type: "connected",
+
+ consoleActor: this._consoleActor,
+ threadActor: this._threadActor,
+ tracerActor: this._tracerActor,
+ };
+ }
+
+ try {
+ const { transport, workerTargetForm } = await connectToWorker(
+ this.conn,
+ this._dbg,
+ this.actorID,
+ {
+ sessionContext: createWorkerSessionContext(),
+ }
+ );
+
+ this._consoleActor = workerTargetForm.consoleActor;
+ this._threadActor = workerTargetForm.threadActor;
+ this._tracerActor = workerTargetForm.tracerActor;
+
+ this._transport = transport;
+
+ return {
+ type: "connected",
+
+ consoleActor: this._consoleActor,
+ threadActor: this._threadActor,
+ tracerActor: this._tracerActor,
+
+ url: this._dbg.url,
+ };
+ } catch (error) {
+ return { error: error.toString() };
+ }
+ }
+
+ _onWorkerClose() {
+ this.destroy();
+ }
+
+ _onWorkerError(filename, lineno, message) {
+ console.error("ERROR:", filename, ":", lineno, ":", message);
+ }
+
+ _getServiceWorkerRegistrationInfo() {
+ return swm.getRegistrationByPrincipal(this._dbg.principal, this._dbg.url);
+ }
+
+ _detach() {
+ if (this._threadActor !== null) {
+ this._transport.close();
+ this._transport = null;
+ this._threadActor = null;
+ }
+
+ this._dbg.removeListener(this._dbgListener);
+ this._attached = false;
+ }
+}
+
+exports.WorkerDescriptorActor = WorkerDescriptorActor;
diff --git a/devtools/server/actors/device.js b/devtools/server/actors/device.js
new file mode 100644
index 0000000000..2aa05959e5
--- /dev/null
+++ b/devtools/server/actors/device.js
@@ -0,0 +1,74 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { deviceSpec } = require("resource://devtools/shared/specs/device.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const { getSystemInfo } = require("resource://devtools/shared/system.js");
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+exports.DeviceActor = class DeviceActor extends Actor {
+ constructor(conn) {
+ super(conn, deviceSpec);
+ // pageshow and pagehide event release wake lock, so we have to acquire
+ // wake lock again by pageshow event
+ this._onPageShow = this._onPageShow.bind(this);
+ if (this._window) {
+ this._window.addEventListener("pageshow", this._onPageShow, true);
+ }
+ this._acquireWakeLock();
+ }
+
+ destroy() {
+ super.destroy();
+ this._releaseWakeLock();
+ if (this._window) {
+ this._window.removeEventListener("pageshow", this._onPageShow, true);
+ }
+ }
+
+ getDescription() {
+ return Object.assign({}, getSystemInfo(), {
+ canDebugServiceWorkers: true,
+ });
+ }
+
+ _acquireWakeLock() {
+ if (AppConstants.platform !== "android") {
+ return;
+ }
+
+ const pm = Cc["@mozilla.org/power/powermanagerservice;1"].getService(
+ Ci.nsIPowerManagerService
+ );
+ this._wakelock = pm.newWakeLock("screen", this._window);
+ }
+
+ _releaseWakeLock() {
+ if (this._wakelock) {
+ try {
+ this._wakelock.unlock();
+ } catch (e) {
+ // Ignore error since wake lock is already unlocked
+ }
+ this._wakelock = null;
+ }
+ }
+
+ _onPageShow() {
+ this._releaseWakeLock();
+ this._acquireWakeLock();
+ }
+
+ get _window() {
+ return Services.wm.getMostRecentWindow(DevToolsServer.chromeWindowType);
+ }
+};
diff --git a/devtools/server/actors/emulation/moz.build b/devtools/server/actors/emulation/moz.build
new file mode 100644
index 0000000000..cf229e6fe1
--- /dev/null
+++ b/devtools/server/actors/emulation/moz.build
@@ -0,0 +1,10 @@
+# -*- 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(
+ "responsive.js",
+ "touch-simulator.js",
+)
diff --git a/devtools/server/actors/emulation/responsive.js b/devtools/server/actors/emulation/responsive.js
new file mode 100644
index 0000000000..829579cab6
--- /dev/null
+++ b/devtools/server/actors/emulation/responsive.js
@@ -0,0 +1,83 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ responsiveSpec,
+} = require("resource://devtools/shared/specs/responsive.js");
+
+/**
+ * This actor overrides various browser features to simulate different environments to
+ * test how pages perform under various conditions.
+ *
+ * The design below, which saves the previous value of each property before setting, is
+ * needed because it's possible to have multiple copies of this actor for a single page.
+ * When some instance of this actor changes a property, we want it to be able to restore
+ * that property to the way it was found before the change.
+ *
+ * A subtle aspect of the code below is that all get* methods must return non-undefined
+ * values, so that the absence of a previous value can be distinguished from the value for
+ * "no override" for each of the properties.
+ */
+class ResponsiveActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, responsiveSpec);
+ this.targetActor = targetActor;
+ this.docShell = targetActor.docShell;
+ }
+
+ destroy() {
+ this.targetActor = null;
+ this.docShell = null;
+
+ super.destroy();
+ }
+
+ get win() {
+ return this.docShell.chromeEventHandler.ownerGlobal;
+ }
+
+ /* Touch events override */
+
+ _previousTouchEventsOverride = undefined;
+
+ /**
+ * Set the current element picker state.
+ *
+ * True means the element picker is currently active and we should not be emulating
+ * touch events.
+ * False means the element picker is not active and it is ok to emulate touch events.
+ *
+ * This actor method is meant to be called by the DevTools front-end. The reason for
+ * this is the following:
+ * RDM is the only current consumer of the touch simulator. RDM instantiates this actor
+ * on its own, whether or not the Toolbox is opened. That means it does so in its own
+ * DevTools Server instance.
+ * When the Toolbox is running, it uses a different DevToolsServer. Therefore, it is not
+ * possible for the touch simulator to know whether the picker is active or not. This
+ * state has to be sent by the client code of the Toolbox to this actor.
+ * If a future use case arises where we want to use the touch simulator from the Toolbox
+ * too, then we could add code in here to detect the picker mode as described in
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1409085#c3
+
+ * @param {Boolean} state
+ * @param {String} pickerType
+ */
+ setElementPickerState(state, pickerType) {
+ this.targetActor.touchSimulator.setElementPickerState(state, pickerType);
+ }
+
+ /**
+ * Dispatches an "orientationchange" event.
+ */
+ async dispatchOrientationChangeEvent() {
+ const { CustomEvent } = this.win;
+ const orientationChangeEvent = new CustomEvent("orientationchange");
+ this.win.dispatchEvent(orientationChangeEvent);
+ }
+}
+
+exports.ResponsiveActor = ResponsiveActor;
diff --git a/devtools/server/actors/emulation/touch-simulator.js b/devtools/server/actors/emulation/touch-simulator.js
new file mode 100644
index 0000000000..4d4b6b4c6e
--- /dev/null
+++ b/devtools/server/actors/emulation/touch-simulator.js
@@ -0,0 +1,309 @@
+/* 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,
+ "PICKER_TYPES",
+ "resource://devtools/shared/picker-constants.js"
+);
+
+var isClickHoldEnabled = Services.prefs.getBoolPref(
+ "ui.click_hold_context_menus"
+);
+var clickHoldDelay = Services.prefs.getIntPref(
+ "ui.click_hold_context_menus.delay",
+ 500
+);
+
+// Touch state constants are derived from values defined in: nsIDOMWindowUtils.idl
+const TOUCH_CONTACT = 0x02;
+const TOUCH_REMOVE = 0x04;
+
+const TOUCH_STATES = {
+ touchstart: TOUCH_CONTACT,
+ touchmove: TOUCH_CONTACT,
+ touchend: TOUCH_REMOVE,
+};
+
+const EVENTS_TO_HANDLE = [
+ "mousedown",
+ "mousemove",
+ "mouseup",
+ "touchstart",
+ "touchend",
+ "mouseenter",
+ "mouseover",
+ "mouseout",
+ "mouseleave",
+];
+
+const kStateHover = 0x00000004; // ElementState::HOVER
+
+/**
+ * Simulate touch events for platforms where they aren't generally available.
+ */
+class TouchSimulator {
+ /**
+ * @param {ChromeEventHandler} simulatorTarget: The object we'll use to listen for click
+ * and touch events to handle.
+ */
+ constructor(simulatorTarget) {
+ this.simulatorTarget = simulatorTarget;
+ this._currentPickerMap = new Map();
+ }
+
+ enabled = false;
+
+ start() {
+ if (this.enabled) {
+ // Simulator is already started
+ return;
+ }
+
+ EVENTS_TO_HANDLE.forEach(evt => {
+ // Only listen trusted events to prevent messing with
+ // event dispatched manually within content documents
+ this.simulatorTarget.addEventListener(evt, this, true, false);
+ });
+
+ this.enabled = true;
+ }
+
+ stop() {
+ if (!this.enabled) {
+ // Simulator isn't running
+ return;
+ }
+ EVENTS_TO_HANDLE.forEach(evt => {
+ this.simulatorTarget.removeEventListener(evt, this, true);
+ });
+ this.enabled = false;
+ }
+
+ _isPicking() {
+ const types = Object.values(PICKER_TYPES);
+ return types.some(type => this._currentPickerMap.get(type));
+ }
+
+ /**
+ * Set the state value for one of DevTools pickers (either eyedropper or
+ * element picker).
+ * If any content picker is currently active, we should not be emulating
+ * touch events. Otherwise it is ok to emulate touch events.
+ * In theory only one picker can ever be active at a time, but tracking the
+ * different pickers independantly avoids race issues in the client code.
+ *
+ * @param {Boolean} state
+ * True if the picker is currently active, false otherwise.
+ * @param {String} pickerType
+ * One of PICKER_TYPES.
+ */
+ setElementPickerState(state, pickerType) {
+ if (!Object.values(PICKER_TYPES).includes(pickerType)) {
+ throw new Error(
+ "Unsupported type in setElementPickerState: " + pickerType
+ );
+ }
+ this._currentPickerMap.set(pickerType, state);
+ }
+
+ // eslint-disable-next-line complexity
+ handleEvent(evt) {
+ // Bail out if devtools is in pick mode in the same tab.
+ if (this._isPicking()) {
+ return;
+ }
+
+ const content = this.getContent(evt.target);
+ if (!content) {
+ return;
+ }
+
+ // App touchstart & touchend should also be dispatched on the system app
+ // to match on-device behavior.
+ if (evt.type.startsWith("touch")) {
+ const sysFrame = content.realFrameElement;
+ if (!sysFrame) {
+ return;
+ }
+ const sysDocument = sysFrame.ownerDocument;
+ const sysWindow = sysDocument.defaultView;
+
+ const touchEvent = sysDocument.createEvent("touchevent");
+ const touch = evt.touches[0] || evt.changedTouches[0];
+ const point = sysDocument.createTouch(
+ sysWindow,
+ sysFrame,
+ 0,
+ touch.pageX,
+ touch.pageY,
+ touch.screenX,
+ touch.screenY,
+ touch.clientX,
+ touch.clientY,
+ 1,
+ 1,
+ 0,
+ 0
+ );
+
+ const touches = sysDocument.createTouchList(point);
+ const targetTouches = touches;
+ const changedTouches = touches;
+ touchEvent.initTouchEvent(
+ evt.type,
+ true,
+ true,
+ sysWindow,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ touches,
+ targetTouches,
+ changedTouches
+ );
+ sysFrame.dispatchEvent(touchEvent);
+ return;
+ }
+
+ // Ignore all but real mouse event coming from physical mouse
+ // (especially ignore mouse event being dispatched from a touch event)
+ if (
+ evt.button ||
+ evt.inputSource != evt.MOZ_SOURCE_MOUSE ||
+ evt.isSynthesized
+ ) {
+ return;
+ }
+
+ const eventTarget = this.target;
+ let type = "";
+ switch (evt.type) {
+ case "mouseenter":
+ case "mouseover":
+ case "mouseout":
+ case "mouseleave":
+ // Don't propagate events which are not related to touch events
+ evt.stopPropagation();
+ evt.preventDefault();
+
+ // We don't want to trigger any visual changes to elements whose content can
+ // be modified via hover states. We can avoid this by removing the element's
+ // content state.
+ InspectorUtils.removeContentState(evt.target, kStateHover);
+ break;
+
+ case "mousedown":
+ this.target = evt.target;
+
+ // If the click-hold feature is enabled, start a timeout to convert long clicks
+ // into contextmenu events.
+ // Just don't do it if the event occurred on a scrollbar.
+ if (isClickHoldEnabled && !evt.originalTarget.closest("scrollbar")) {
+ this._contextMenuTimeout = this.sendContextMenu(evt);
+ }
+
+ this.startX = evt.pageX;
+ this.startY = evt.pageY;
+
+ // Capture events so if a different window show up the events
+ // won't be dispatched to something else.
+ evt.target.setCapture(false);
+
+ type = "touchstart";
+ break;
+
+ case "mousemove":
+ if (!eventTarget) {
+ // Don't propagate mousemove event when touchstart event isn't fired
+ evt.stopPropagation();
+ return;
+ }
+
+ type = "touchmove";
+ break;
+
+ case "mouseup":
+ if (!eventTarget) {
+ return;
+ }
+ this.target = null;
+
+ content.clearTimeout(this._contextMenuTimeout);
+ type = "touchend";
+
+ // Only register click listener after mouseup to ensure
+ // catching only real user click. (Especially ignore click
+ // being dispatched on form submit)
+ if (evt.detail == 1) {
+ this.simulatorTarget.addEventListener("click", this, {
+ capture: true,
+ once: true,
+ });
+ }
+ break;
+ }
+
+ const target = eventTarget || this.target;
+ if (target && type) {
+ this.synthesizeNativeTouch(content, evt.screenX, evt.screenY, type);
+ }
+
+ evt.preventDefault();
+ evt.stopImmediatePropagation();
+ }
+
+ sendContextMenu({ target, clientX, clientY, screenX, screenY }) {
+ const view = target.ownerGlobal;
+ const { MouseEvent } = view;
+ const evt = new MouseEvent("contextmenu", {
+ bubbles: true,
+ cancelable: true,
+ view,
+ screenX,
+ screenY,
+ clientX,
+ clientY,
+ });
+ const content = this.getContent(target);
+ const timeout = content.setTimeout(() => {
+ target.dispatchEvent(evt);
+ }, clickHoldDelay);
+
+ return timeout;
+ }
+
+ /**
+ * Synthesizes a native touch action on a given target element.
+ *
+ * @param {Window} win
+ * The target window.
+ * @param {Number} screenX
+ * The `x` screen coordinate relative to the screen origin.
+ * @param {Number} screenY
+ * The `y` screen coordinate relative to the screen origin.
+ * @param {String} type
+ * A key appearing in the TOUCH_STATES associative array.
+ */
+ synthesizeNativeTouch(win, screenX, screenY, type) {
+ // Native events work in device pixels.
+ const utils = win.windowUtils;
+ const deviceScale = win.devicePixelRatio;
+ const pt = { x: screenX * deviceScale, y: screenY * deviceScale };
+
+ utils.sendNativeTouchPoint(0, TOUCH_STATES[type], pt.x, pt.y, 1, 90, null);
+ return true;
+ }
+
+ getContent(target) {
+ const win = target?.ownerDocument ? target.ownerGlobal : null;
+ return win;
+ }
+}
+
+exports.TouchSimulator = TouchSimulator;
diff --git a/devtools/server/actors/environment.js b/devtools/server/actors/environment.js
new file mode 100644
index 0000000000..2a9b4af07d
--- /dev/null
+++ b/devtools/server/actors/environment.js
@@ -0,0 +1,206 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ environmentSpec,
+} = require("resource://devtools/shared/specs/environment.js");
+
+const {
+ createValueGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+
+/**
+ * Creates an EnvironmentActor. EnvironmentActors are responsible for listing
+ * the bindings introduced by a lexical environment and assigning new values to
+ * those identifier bindings.
+ *
+ * @param Debugger.Environment aEnvironment
+ * The lexical environment that will be used to create the actor.
+ * @param ThreadActor aThreadActor
+ * The parent thread actor that contains this environment.
+ */
+class EnvironmentActor extends Actor {
+ constructor(environment, threadActor) {
+ super(threadActor.conn, environmentSpec);
+
+ this.obj = environment;
+ this.threadActor = threadActor;
+ }
+
+ /**
+ * When the Environment Actor is destroyed it removes the
+ * Debugger.Environment.actor field so that environment does not
+ * reference a destroyed actor.
+ */
+ destroy() {
+ this.obj.actor = null;
+ super.destroy();
+ }
+
+ /**
+ * Return an environment form for use in a protocol message.
+ */
+ form() {
+ const form = { actor: this.actorID };
+
+ // What is this environment's type?
+ if (this.obj.type == "declarative") {
+ form.type = this.obj.calleeScript ? "function" : "block";
+ } else {
+ form.type = this.obj.type;
+ }
+
+ form.scopeKind = this.obj.scopeKind;
+
+ // Does this environment have a parent?
+ if (this.obj.parent) {
+ form.parent = this.threadActor
+ .createEnvironmentActor(this.obj.parent, this.getParent())
+ .form();
+ }
+
+ // Does this environment reflect the properties of an object as variables?
+ if (this.obj.type == "object" || this.obj.type == "with") {
+ form.object = createValueGrip(
+ this.obj.object,
+ this.getParent(),
+ this.threadActor.objectGrip
+ );
+ }
+
+ // Is this the environment created for a function call?
+ if (this.obj.calleeScript) {
+ // Client only uses "displayName" for "function".
+ // Create a fake object actor containing only "displayName" as replacement
+ // for the no longer available obj.callee (see bug 1663847).
+ // See bug 1664218 for cleanup.
+ form.function = { displayName: this.obj.calleeScript.displayName };
+ }
+
+ // Shall we list this environment's bindings?
+ if (this.obj.type == "declarative") {
+ form.bindings = this.bindings();
+ }
+
+ return form;
+ }
+
+ /**
+ * Handle a protocol request to fully enumerate the bindings introduced by the
+ * lexical environment.
+ */
+ bindings() {
+ const bindings = { arguments: [], variables: {} };
+
+ // TODO: this part should be removed in favor of the commented-out part
+ // below when getVariableDescriptor lands (bug 725815).
+ if (typeof this.obj.getVariable != "function") {
+ // if (typeof this.obj.getVariableDescriptor != "function") {
+ return bindings;
+ }
+
+ let parameterNames;
+ if (this.obj.calleeScript) {
+ parameterNames = this.obj.calleeScript.parameterNames;
+ } else {
+ parameterNames = [];
+ }
+ for (const name of parameterNames) {
+ const arg = {};
+ const value = this.obj.getVariable(name);
+
+ // TODO: this part should be removed in favor of the commented-out part
+ // below when getVariableDescriptor lands (bug 725815).
+ const desc = {
+ value,
+ configurable: false,
+ writable: !value?.optimizedOut,
+ enumerable: true,
+ };
+
+ // let desc = this.obj.getVariableDescriptor(name);
+ const descForm = {
+ enumerable: true,
+ configurable: desc.configurable,
+ };
+ if ("value" in desc) {
+ descForm.value = createValueGrip(
+ desc.value,
+ this.getParent(),
+ this.threadActor.objectGrip
+ );
+ descForm.writable = desc.writable;
+ } else {
+ descForm.get = createValueGrip(
+ desc.get,
+ this.getParent(),
+ this.threadActor.objectGrip
+ );
+ descForm.set = createValueGrip(
+ desc.set,
+ this.getParent(),
+ this.threadActor.objectGrip
+ );
+ }
+ arg[name] = descForm;
+ bindings.arguments.push(arg);
+ }
+
+ for (const name of this.obj.names()) {
+ if (
+ bindings.arguments.some(function exists(element) {
+ return !!element[name];
+ })
+ ) {
+ continue;
+ }
+
+ const value = this.obj.getVariable(name);
+
+ // TODO: this part should be removed in favor of the commented-out part
+ // below when getVariableDescriptor lands.
+ const desc = {
+ value,
+ configurable: false,
+ writable: !(
+ value &&
+ (value.optimizedOut || value.uninitialized || value.missingArguments)
+ ),
+ enumerable: true,
+ };
+
+ // let desc = this.obj.getVariableDescriptor(name);
+ const descForm = {
+ enumerable: true,
+ configurable: desc.configurable,
+ };
+ if ("value" in desc) {
+ descForm.value = createValueGrip(
+ desc.value,
+ this.getParent(),
+ this.threadActor.objectGrip
+ );
+ descForm.writable = desc.writable;
+ } else {
+ descForm.get = createValueGrip(
+ desc.get || undefined,
+ this.getParent(),
+ this.threadActor.objectGrip
+ );
+ descForm.set = createValueGrip(
+ desc.set || undefined,
+ this.getParent(),
+ this.threadActor.objectGrip
+ );
+ }
+ bindings.variables[name] = descForm;
+ }
+
+ return bindings;
+ }
+}
+
+exports.EnvironmentActor = EnvironmentActor;
diff --git a/devtools/server/actors/errordocs.js b/devtools/server/actors/errordocs.js
new file mode 100644
index 0000000000..03363915de
--- /dev/null
+++ b/devtools/server/actors/errordocs.js
@@ -0,0 +1,222 @@
+/* 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/. */
+
+/**
+ * A mapping of error message names to external documentation. Any error message
+ * included here will be displayed alongside its link in the web console.
+ */
+
+"use strict";
+
+// Worker contexts do not support Services; in that case we have to rely
+// on the support URL redirection.
+
+loader.lazyGetter(this, "supportBaseURL", () => {
+ // Fallback URL used for worker targets, as well as when app.support.baseURL
+ // cannot be formatted.
+ let url = "https://support.mozilla.org/kb/";
+
+ if (!isWorker) {
+ try {
+ // formatURLPref might throw if tokens used in app.support.baseURL
+ // are not available for the current binary. See Bug 1755626.
+ url = Services.urlFormatter.formatURLPref("app.support.baseURL");
+ } catch (e) {
+ console.warn(
+ `Failed to format app.support.baseURL, falling back to ${url} (${e.message})`
+ );
+ }
+ }
+ return url;
+});
+
+const baseErrorURL =
+ "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Errors/";
+const params =
+ "?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default";
+
+const ErrorDocs = {
+ JSMSG_READ_ONLY: "Read-only",
+ JSMSG_BAD_ARRAY_LENGTH: "Invalid_array_length",
+ JSMSG_NEGATIVE_REPETITION_COUNT: "Negative_repetition_count",
+ JSMSG_RESULTING_STRING_TOO_LARGE: "Resulting_string_too_large",
+ JSMSG_BAD_RADIX: "Bad_radix",
+ JSMSG_PRECISION_RANGE: "Precision_range",
+ JSMSG_STMT_AFTER_RETURN: "Stmt_after_return",
+ JSMSG_NOT_A_CODEPOINT: "Not_a_codepoint",
+ JSMSG_BAD_SORT_ARG: "Array_sort_argument",
+ JSMSG_UNEXPECTED_TYPE: "Unexpected_type",
+ JSMSG_NOT_DEFINED: "Not_defined",
+ JSMSG_NOT_FUNCTION: "Not_a_function",
+ JSMSG_EQUAL_AS_ASSIGN: "Equal_as_assign",
+ JSMSG_UNDEFINED_PROP: "Undefined_prop",
+ JSMSG_DEPRECATED_PRAGMA: "Deprecated_source_map_pragma",
+ JSMSG_DEPRECATED_USAGE: "Deprecated_caller_or_arguments_usage",
+ JSMSG_CANT_DELETE: "Cant_delete",
+ JSMSG_VAR_HIDES_ARG: "Var_hides_argument",
+ JSMSG_JSON_BAD_PARSE: "JSON_bad_parse",
+ JSMSG_UNDECLARED_VAR: "Undeclared_var",
+ JSMSG_UNEXPECTED_TOKEN: "Unexpected_token",
+ JSMSG_BAD_OCTAL: "Bad_octal",
+ JSMSG_PROPERTY_ACCESS_DENIED: "Property_access_denied",
+ JSMSG_NO_PROPERTIES: "No_properties",
+ JSMSG_ALREADY_HAS_PRAGMA: "Already_has_pragma",
+ JSMSG_BAD_RETURN_OR_YIELD: "Bad_return_or_yield",
+ JSMSG_UNEXPECTED_TOKEN_NO_EXPECT: "Missing_semicolon_before_statement",
+ JSMSG_OVER_RECURSED: "Too_much_recursion",
+ JSMSG_BRACKET_AFTER_LIST: "Missing_bracket_after_list",
+ JSMSG_PAREN_AFTER_ARGS: "Missing_parenthesis_after_argument_list",
+ JSMSG_MORE_ARGS_NEEDED: "More_arguments_needed",
+ JSMSG_BAD_LEFTSIDE_OF_ASS: "Invalid_assignment_left-hand_side",
+ JSMSG_UNTERMINATED_STRING: "Unterminated_string_literal",
+ JSMSG_NOT_CONSTRUCTOR: "Not_a_constructor",
+ JSMSG_CURLY_AFTER_LIST: "Missing_curly_after_property_list",
+ JSMSG_DEPRECATED_FOR_EACH: "For-each-in_loops_are_deprecated",
+ JSMSG_STRICT_NON_SIMPLE_PARAMS: "Strict_Non_Simple_Params",
+ JSMSG_DEAD_OBJECT: "Dead_object",
+ JSMSG_OBJECT_REQUIRED: "No_non-null_object",
+ JSMSG_IDSTART_AFTER_NUMBER: "Identifier_after_number",
+ JSMSG_DEPRECATED_EXPR_CLOSURE: "Deprecated_expression_closures",
+ JSMSG_ILLEGAL_CHARACTER: "Illegal_character",
+ JSMSG_BAD_REGEXP_FLAG: "Bad_regexp_flag",
+ JSMSG_INVALID_FOR_IN_DECL_WITH_INIT: "Invalid_for-in_initializer",
+ JSMSG_CANT_REDEFINE_PROP: "Cant_redefine_property",
+ JSMSG_COLON_AFTER_ID: "Missing_colon_after_property_id",
+ JSMSG_IN_NOT_OBJECT: "in_operator_no_object",
+ JSMSG_CURLY_AFTER_BODY: "Missing_curly_after_function_body",
+ JSMSG_NAME_AFTER_DOT: "Missing_name_after_dot_operator",
+ JSMSG_DEPRECATED_OCTAL: "Deprecated_octal",
+ JSMSG_PAREN_AFTER_COND: "Missing_parenthesis_after_condition",
+ JSMSG_JSON_CYCLIC_VALUE: "Cyclic_object_value",
+ JSMSG_NO_VARIABLE_NAME: "No_variable_name",
+ JSMSG_UNNAMED_FUNCTION_STMT: "Unnamed_function_statement",
+ JSMSG_CANT_DEFINE_PROP_OBJECT_NOT_EXTENSIBLE:
+ "Cant_define_property_object_not_extensible",
+ JSMSG_TYPED_ARRAY_BAD_ARGS: "Typed_array_invalid_arguments",
+ JSMSG_GETTER_ONLY: "Getter_only",
+ JSMSG_INVALID_DATE: "Invalid_date",
+ JSMSG_DEPRECATED_STRING_METHOD: "Deprecated_String_generics",
+ JSMSG_RESERVED_ID: "Reserved_identifier",
+ JSMSG_BAD_CONST_ASSIGN: "Invalid_const_assignment",
+ JSMSG_BAD_CONST_DECL: "Missing_initializer_in_const",
+ JSMSG_OF_AFTER_FOR_LOOP_DECL: "Invalid_for-of_initializer",
+ JSMSG_BAD_URI: "Malformed_URI",
+ JSMSG_DEPRECATED_DELETE_OPERAND: "Delete_in_strict_mode",
+ JSMSG_MISSING_FORMAL: "Missing_formal_parameter",
+ JSMSG_CANT_TRUNCATE_ARRAY: "Non_configurable_array_element",
+ JSMSG_INCOMPATIBLE_PROTO: "Called_on_incompatible_type",
+ JSMSG_INCOMPATIBLE_METHOD: "Called_on_incompatible_type",
+ JSMSG_BAD_INSTANCEOF_RHS: "invalid_right_hand_side_instanceof_operand",
+ JSMSG_EMPTY_ARRAY_REDUCE: "Reduce_of_empty_array_with_no_initial_value",
+ JSMSG_NOT_ITERABLE: "is_not_iterable",
+ JSMSG_PROPERTY_FAIL: "cant_access_property",
+ JSMSG_PROPERTY_FAIL_EXPR: "cant_access_property",
+ JSMSG_REDECLARED_VAR: "Redeclared_parameter",
+ JSMSG_MISMATCHED_PLACEMENT: "Mismatched placement",
+ JSMSG_SET_NON_OBJECT_RECEIVER: "Cant_assign_to_property",
+};
+
+const MIXED_CONTENT_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/Security/Mixed_content";
+const TRACKING_PROTECTION_LEARN_MORE =
+ "https://developer.mozilla.org/Firefox/Privacy/Tracking_Protection";
+const INSECURE_PASSWORDS_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/Security/Insecure_passwords";
+const PUBLIC_KEY_PINS_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/HTTP/Public_Key_Pinning";
+const STRICT_TRANSPORT_SECURITY_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/HTTP/Headers/Strict-Transport-Security";
+const MIME_TYPE_MISMATCH_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/HTTP/Headers/X-Content-Type-Options";
+const SOURCE_MAP_LEARN_MORE =
+ "https://firefox-source-docs.mozilla.org/devtools-user/debugger/source_map_errors/";
+const TLS_LEARN_MORE =
+ "https://blog.mozilla.org/security/2018/10/15/removing-old-versions-of-tls/";
+const X_FRAME_OPTIONS_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/HTTP/Headers/X-Frame-Options";
+const REQUEST_STORAGE_ACCESS_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/API/Document/requestStorageAccess";
+const DOCTYPE_MODES_LEARN_MORE =
+ "https://developer.mozilla.org/docs/Web/HTML/Quirks_Mode_and_Standards_Mode";
+
+const ErrorCategories = {
+ "X-Frame-Options": X_FRAME_OPTIONS_LEARN_MORE,
+ "Insecure Password Field": INSECURE_PASSWORDS_LEARN_MORE,
+ "Mixed Content Message": MIXED_CONTENT_LEARN_MORE,
+ "Mixed Content Blocker": MIXED_CONTENT_LEARN_MORE,
+ "Invalid HPKP Headers": PUBLIC_KEY_PINS_LEARN_MORE,
+ "Invalid HSTS Headers": STRICT_TRANSPORT_SECURITY_LEARN_MORE,
+ "Tracking Protection": TRACKING_PROTECTION_LEARN_MORE,
+ MIMEMISMATCH: MIME_TYPE_MISMATCH_LEARN_MORE,
+ "source map": SOURCE_MAP_LEARN_MORE,
+ TLS: TLS_LEARN_MORE,
+ requestStorageAccess: REQUEST_STORAGE_ACCESS_LEARN_MORE,
+ HTTPSOnly: supportBaseURL + "https-only-prefs",
+ HTML_PARSER__DOCTYPE: DOCTYPE_MODES_LEARN_MORE,
+};
+
+const baseCorsErrorUrl =
+ "https://developer.mozilla.org/docs/Web/HTTP/CORS/Errors/";
+const corsParams =
+ "?utm_source=devtools&utm_medium=firefox-cors-errors&utm_campaign=default";
+const CorsErrorDocs = {
+ CORSDisabled: "CORSDisabled",
+ CORSDidNotSucceed2: "CORSDidNotSucceed",
+ CORSOriginHeaderNotAdded: "CORSOriginHeaderNotAdded",
+ CORSExternalRedirectNotAllowed: "CORSExternalRedirectNotAllowed",
+ CORSRequestNotHttp: "CORSRequestNotHttp",
+ CORSMissingAllowOrigin2: "CORSMissingAllowOrigin",
+ CORSMultipleAllowOriginNotAllowed: "CORSMultipleAllowOriginNotAllowed",
+ CORSAllowOriginNotMatchingOrigin: "CORSAllowOriginNotMatchingOrigin",
+ CORSNotSupportingCredentials: "CORSNotSupportingCredentials",
+ CORSMethodNotFound: "CORSMethodNotFound",
+ CORSMissingAllowCredentials: "CORSMissingAllowCredentials",
+ CORSPreflightDidNotSucceed3: "CORSPreflightDidNotSucceed",
+ CORSInvalidAllowMethod: "CORSInvalidAllowMethod",
+ CORSInvalidAllowHeader: "CORSInvalidAllowHeader",
+ CORSMissingAllowHeaderFromPreflight2: "CORSMissingAllowHeaderFromPreflight",
+};
+
+const baseStorageAccessPolicyErrorUrl =
+ "https://developer.mozilla.org/docs/Mozilla/Firefox/Privacy/Storage_access_policy/Errors/";
+const storageAccessPolicyParams =
+ "?utm_source=devtools&utm_medium=firefox-cookie-errors&utm_campaign=default";
+const StorageAccessPolicyErrorDocs = {
+ cookieBlockedPermission: "CookieBlockedByPermission",
+ cookieBlockedTracker: "CookieBlockedTracker",
+ cookieBlockedAll: "CookieBlockedAll",
+ cookieBlockedForeign: "CookieBlockedForeign",
+ cookiePartitionedForeign: "CookiePartitionedForeign",
+};
+
+exports.GetURL = error => {
+ if (!error) {
+ return undefined;
+ }
+
+ const doc = ErrorDocs[error.errorMessageName];
+ if (doc) {
+ return baseErrorURL + doc + params;
+ }
+
+ const corsDoc = CorsErrorDocs[error.category];
+ if (corsDoc) {
+ return baseCorsErrorUrl + corsDoc + corsParams;
+ }
+
+ const storageAccessPolicyDoc = StorageAccessPolicyErrorDocs[error.category];
+ if (storageAccessPolicyDoc) {
+ return (
+ baseStorageAccessPolicyErrorUrl +
+ storageAccessPolicyDoc +
+ storageAccessPolicyParams
+ );
+ }
+
+ const categoryURL = ErrorCategories[error.category];
+ if (categoryURL) {
+ return categoryURL + params;
+ }
+ return undefined;
+};
diff --git a/devtools/server/actors/frame.js b/devtools/server/actors/frame.js
new file mode 100644
index 0000000000..be4f5e3eb3
--- /dev/null
+++ b/devtools/server/actors/frame.js
@@ -0,0 +1,225 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+const { Pool } = require("resource://devtools/shared/protocol/Pool.js");
+const { frameSpec } = require("resource://devtools/shared/specs/frame.js");
+
+const Debugger = require("Debugger");
+const { assert } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ createValueGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+
+function formatDisplayName(frame) {
+ if (frame.type === "call") {
+ const callee = frame.callee;
+ return callee.name || callee.userDisplayName || callee.displayName;
+ }
+
+ return `(${frame.type})`;
+}
+
+function isDeadSavedFrame(savedFrame) {
+ return Cu && Cu.isDeadWrapper(savedFrame);
+}
+function isValidSavedFrame(threadActor, savedFrame) {
+ return (
+ !isDeadSavedFrame(savedFrame) &&
+ // If the frame's source is unknown to the debugger, then we ignore it
+ // since the frame likely does not belong to a realm that is marked
+ // as a debuggee.
+ // This check will also fail if the frame would have been known but was
+ // GCed before the debugger was opened on the page.
+ // TODO: Use SavedFrame's security principal to limit non-debuggee frames
+ // and pass all unknown frames to the debugger as a URL with no sourceID.
+ getSavedFrameSource(threadActor, savedFrame)
+ );
+}
+function getSavedFrameSource(threadActor, savedFrame) {
+ return threadActor.sourcesManager.getSourceActorByInternalSourceId(
+ savedFrame.sourceId
+ );
+}
+function getSavedFrameParent(threadActor, savedFrame) {
+ if (isDeadSavedFrame(savedFrame)) {
+ return null;
+ }
+
+ while (true) {
+ savedFrame = savedFrame.parent || savedFrame.asyncParent;
+
+ // If the saved frame is a dead wrapper, we don't have any way to keep
+ // stepping through parent frames.
+ if (!savedFrame || isDeadSavedFrame(savedFrame)) {
+ savedFrame = null;
+ break;
+ }
+
+ if (isValidSavedFrame(threadActor, savedFrame)) {
+ break;
+ }
+ }
+ return savedFrame;
+}
+
+/**
+ * An actor for a specified stack frame.
+ */
+class FrameActor extends Actor {
+ /**
+ * Creates the Frame actor.
+ *
+ * @param frame Debugger.Frame|SavedFrame
+ * The debuggee frame.
+ * @param threadActor ThreadActor
+ * The parent thread actor for this frame.
+ */
+ constructor(frame, threadActor, depth) {
+ super(threadActor.conn, frameSpec);
+
+ this.frame = frame;
+ this.threadActor = threadActor;
+ this.depth = depth;
+ }
+
+ /**
+ * A pool that contains frame-lifetime objects, like the environment.
+ */
+ _frameLifetimePool = null;
+ get frameLifetimePool() {
+ if (!this._frameLifetimePool) {
+ this._frameLifetimePool = new Pool(this.conn, "frame");
+ }
+ return this._frameLifetimePool;
+ }
+
+ /**
+ * Finalization handler that is called when the actor is being evicted from
+ * the pool.
+ */
+ destroy() {
+ if (this._frameLifetimePool) {
+ this._frameLifetimePool.destroy();
+ this._frameLifetimePool = null;
+ }
+ super.destroy();
+ }
+
+ getEnvironment() {
+ try {
+ if (!this.frame.environment) {
+ return {};
+ }
+ } catch (e) {
+ // |this.frame| might not be live. FIXME Bug 1477030 we shouldn't be
+ // using frames we derived from a point where we are not currently
+ // paused at.
+ return {};
+ }
+
+ const envActor = this.threadActor.createEnvironmentActor(
+ this.frame.environment,
+ this.frameLifetimePool
+ );
+
+ return envActor.form();
+ }
+
+ /**
+ * Returns a frame form for use in a protocol message.
+ */
+ form() {
+ // SavedFrame actors have their own frame handling.
+ if (!(this.frame instanceof Debugger.Frame)) {
+ // The Frame actor shouldn't be used after evaluation is resumed, so
+ // there shouldn't be an easy way for the saved frame to be referenced
+ // once it has died.
+ assert(!isDeadSavedFrame(this.frame));
+
+ const obj = {
+ actor: this.actorID,
+ // TODO: Bug 1610418 - Consider updating SavedFrame to have a type.
+ type: "dead",
+ asyncCause: this.frame.asyncCause,
+ state: "dead",
+ displayName: this.frame.functionDisplayName,
+ arguments: [],
+ where: {
+ // The frame's source should always be known because
+ // getSavedFrameParent will skip over frames with unknown sources.
+ actor: getSavedFrameSource(this.threadActor, this.frame).actorID,
+ line: this.frame.line,
+ // SavedFrame objects have a 1-based column number, but this API and
+ // Debugger API objects use a 0-based column value.
+ column: this.frame.column - 1,
+ },
+ oldest: !getSavedFrameParent(this.threadActor, this.frame),
+ };
+
+ return obj;
+ }
+
+ const threadActor = this.threadActor;
+ const form = {
+ actor: this.actorID,
+ type: this.frame.type,
+ asyncCause: this.frame.onStack ? null : "await",
+ state: this.frame.onStack ? "on-stack" : "suspended",
+ };
+
+ if (this.depth) {
+ form.depth = this.depth;
+ }
+
+ if (this.frame.type != "wasmcall") {
+ form.this = createValueGrip(
+ this.frame.this,
+ threadActor._pausePool,
+ threadActor.objectGrip
+ );
+ }
+
+ form.displayName = formatDisplayName(this.frame);
+ form.arguments = this._args();
+
+ if (this.frame.script) {
+ const location = this.threadActor.sourcesManager.getFrameLocation(
+ this.frame
+ );
+ form.where = {
+ actor: location.sourceActor.actorID,
+ line: location.line,
+ column: location.column,
+ };
+ }
+
+ if (!this.frame.older) {
+ form.oldest = true;
+ }
+
+ return form;
+ }
+
+ _args() {
+ if (!this.frame.onStack || !this.frame.arguments) {
+ return [];
+ }
+
+ return this.frame.arguments.map(arg =>
+ createValueGrip(
+ arg,
+ this.threadActor._pausePool,
+ this.threadActor.objectGrip
+ )
+ );
+ }
+}
+
+exports.FrameActor = FrameActor;
+exports.formatDisplayName = formatDisplayName;
+exports.getSavedFrameParent = getSavedFrameParent;
+exports.isValidSavedFrame = isValidSavedFrame;
diff --git a/devtools/server/actors/heap-snapshot-file.js b/devtools/server/actors/heap-snapshot-file.js
new file mode 100644
index 0000000000..f3fd9242b2
--- /dev/null
+++ b/devtools/server/actors/heap-snapshot-file.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";
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ heapSnapshotFileSpec,
+} = require("resource://devtools/shared/specs/heap-snapshot-file.js");
+
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "HeapSnapshotFileUtils",
+ "resource://devtools/shared/heapsnapshot/HeapSnapshotFileUtils.js"
+);
+
+/**
+ * The HeapSnapshotFileActor handles transferring heap snapshot files from the
+ * server to the client. This has to be a global actor in the parent process
+ * because child processes are sandboxed and do not have access to the file
+ * system.
+ */
+exports.HeapSnapshotFileActor = class HeapSnapshotFileActor extends Actor {
+ constructor(conn, parent) {
+ super(conn, heapSnapshotFileSpec);
+
+ if (
+ Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT
+ ) {
+ const err = new Error(
+ "Attempt to create a HeapSnapshotFileActor in a " +
+ "child process! The HeapSnapshotFileActor *MUST* " +
+ "be in the parent process!"
+ );
+ DevToolsUtils.reportException("HeapSnapshotFileActor's constructor", err);
+ }
+ }
+
+ /**
+ * @see MemoryFront.prototype.transferHeapSnapshot
+ */
+ async transferHeapSnapshot(snapshotId) {
+ const snapshotFilePath =
+ HeapSnapshotFileUtils.getHeapSnapshotTempFilePath(snapshotId);
+ if (!snapshotFilePath) {
+ throw new Error(`No heap snapshot with id: ${snapshotId}`);
+ }
+
+ const streamPromise = DevToolsUtils.openFileStream(snapshotFilePath);
+
+ const { size } = await IOUtils.stat(snapshotFilePath);
+ const bulkPromise = this.conn.startBulkSend({
+ actor: this.actorID,
+ type: "heap-snapshot",
+ length: size,
+ });
+
+ const [bulk, stream] = await Promise.all([bulkPromise, streamPromise]);
+
+ try {
+ await bulk.copyFrom(stream);
+ } finally {
+ stream.close();
+ }
+ }
+};
diff --git a/devtools/server/actors/highlighters.js b/devtools/server/actors/highlighters.js
new file mode 100644
index 0000000000..a25bae4781
--- /dev/null
+++ b/devtools/server/actors/highlighters.js
@@ -0,0 +1,379 @@
+/* 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 { Actor } = require("devtools/shared/protocol");
+const { customHighlighterSpec } = require("devtools/shared/specs/highlighters");
+
+const EventEmitter = require("devtools/shared/event-emitter");
+
+loader.lazyRequireGetter(
+ this,
+ "isXUL",
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+
+/**
+ * The registration mechanism for highlighters provides a quick way to
+ * have modular highlighters instead of a hard coded list.
+ */
+const highlighterTypes = new Map();
+
+/**
+ * Returns `true` if a highlighter for the given `typeName` is registered,
+ * `false` otherwise.
+ */
+const isTypeRegistered = typeName => highlighterTypes.has(typeName);
+exports.isTypeRegistered = isTypeRegistered;
+
+/**
+ * Registers a given constructor as highlighter, for the `typeName` given.
+ */
+const registerHighlighter = (typeName, modulePath) => {
+ if (highlighterTypes.has(typeName)) {
+ throw Error(`${typeName} is already registered.`);
+ }
+
+ highlighterTypes.set(typeName, modulePath);
+};
+
+/**
+ * CustomHighlighterActor is a generic Actor that instantiates a custom implementation of
+ * a highlighter class given its type name which must be registered in `highlighterTypes`.
+ * CustomHighlighterActor proxies calls to methods of the highlighter class instance:
+ * constructor(targetActor), show(node, options), hide(), destroy()
+ */
+exports.CustomHighlighterActor = class CustomHighligherActor extends Actor {
+ /**
+ * Create a highlighter instance given its typeName.
+ */
+ constructor(parent, typeName) {
+ super(parent.conn, customHighlighterSpec);
+
+ this._parent = parent;
+
+ const modulePath = highlighterTypes.get(typeName);
+ if (!modulePath) {
+ const list = [...highlighterTypes.keys()];
+
+ throw new Error(`${typeName} isn't a valid highlighter class (${list})`);
+ }
+
+ const constructor = require(modulePath)[typeName];
+ // The assumption is that custom highlighters either need the canvasframe
+ // container to append their elements and thus a non-XUL window or they have
+ // to define a static XULSupported flag that indicates that the highlighter
+ // supports XUL windows. Otherwise, bail out.
+ if (!isXUL(this._parent.targetActor.window) || constructor.XULSupported) {
+ this._highlighterEnv = new HighlighterEnvironment();
+ this._highlighterEnv.initFromTargetActor(parent.targetActor);
+ this._highlighter = new constructor(this._highlighterEnv);
+ if (this._highlighter.on) {
+ this._highlighter.on(
+ "highlighter-event",
+ this._onHighlighterEvent.bind(this)
+ );
+ }
+ } else {
+ throw new Error(
+ "Custom " + typeName + "highlighter cannot be created in a XUL window"
+ );
+ }
+ }
+
+ destroy() {
+ super.destroy();
+ this.finalize();
+ this._parent = null;
+ }
+
+ release() {}
+
+ /**
+ * Get current instance of the highlighter object.
+ */
+ get instance() {
+ return this._highlighter;
+ }
+
+ /**
+ * Show the highlighter.
+ * This calls through to the highlighter instance's |show(node, options)|
+ * method.
+ *
+ * Most custom highlighters are made to highlight DOM nodes, hence the first
+ * NodeActor argument (NodeActor as in devtools/server/actor/inspector).
+ * Note however that some highlighters use this argument merely as a context
+ * node: The SelectorHighlighter for instance uses it as a base node to run the
+ * provided CSS selector on.
+ *
+ * @param {NodeActor} The node to be highlighted
+ * @param {Object} Options for the custom highlighter
+ * @return {Boolean} True, if the highlighter has been successfully shown
+ */
+ show(node, options) {
+ if (!this._highlighter) {
+ return null;
+ }
+
+ const rawNode = node?.rawNode;
+
+ return this._highlighter.show(rawNode, options);
+ }
+
+ /**
+ * Hide the highlighter if it was shown before
+ */
+ hide() {
+ if (this._highlighter) {
+ this._highlighter.hide();
+ }
+ }
+
+ /**
+ * Upon receiving an event from the highlighter, forward it to the client.
+ */
+ _onHighlighterEvent(data) {
+ this.emit("highlighter-event", data);
+ }
+
+ /**
+ * Destroy the custom highlighter implementation.
+ * This method is called automatically just before the actor is destroyed.
+ */
+ finalize() {
+ if (this._highlighter) {
+ if (this._highlighter.off) {
+ this._highlighter.off(
+ "highlighter-event",
+ this._onHighlighterEvent.bind(this)
+ );
+ }
+ this._highlighter.destroy();
+ this._highlighter = null;
+ }
+
+ if (this._highlighterEnv) {
+ this._highlighterEnv.destroy();
+ this._highlighterEnv = null;
+ }
+ }
+};
+
+/**
+ * The HighlighterEnvironment is an object that holds all the required data for
+ * highlighters to work: the window, docShell, event listener target, ...
+ * It also emits "will-navigate", "navigate" and "window-ready" events,
+ * similarly to the WindowGlobalTargetActor.
+ *
+ * It can be initialized either from a WindowGlobalTargetActor (which is the
+ * most frequent way of using it, since highlighters are initialized by
+ * CustomHighlighterActor, which has a targetActor reference).
+ * It can also be initialized just with a window object (which is
+ * useful for when a highlighter is used outside of the devtools server context.
+ */
+
+class HighlighterEnvironment extends EventEmitter {
+ initFromTargetActor(targetActor) {
+ this._targetActor = targetActor;
+
+ const relayedEvents = [
+ "window-ready",
+ "navigate",
+ "will-navigate",
+ "use-simple-highlighters-updated",
+ ];
+
+ this._abortController = new AbortController();
+ const signal = this._abortController.signal;
+ for (const event of relayedEvents) {
+ this._targetActor.on(event, this.relayTargetEvent.bind(this, event), {
+ signal,
+ });
+ }
+ }
+
+ initFromWindow(win) {
+ this._win = win;
+
+ // We need a progress listener to know when the window will navigate/has
+ // navigated.
+ const self = this;
+ this.listener = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ onStateChange(progress, request, flag) {
+ const isStart = flag & Ci.nsIWebProgressListener.STATE_START;
+ const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP;
+ const isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW;
+ const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
+
+ if (progress.DOMWindow !== win) {
+ return;
+ }
+
+ if (isDocument && isStart) {
+ // One of the earliest events that tells us a new URI is being loaded
+ // in this window.
+ self.emit("will-navigate", {
+ window: win,
+ isTopLevel: true,
+ });
+ }
+ if (isWindow && isStop) {
+ self.emit("navigate", {
+ window: win,
+ isTopLevel: true,
+ });
+ }
+ },
+ };
+
+ this.webProgress.addProgressListener(
+ this.listener,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ }
+
+ get isInitialized() {
+ return this._win || this._targetActor;
+ }
+
+ get isXUL() {
+ return isXUL(this.window);
+ }
+
+ get useSimpleHighlightersForReducedMotion() {
+ return this._targetActor?._useSimpleHighlightersForReducedMotion;
+ }
+
+ get window() {
+ if (!this.isInitialized) {
+ throw new Error(
+ "Initialize HighlighterEnvironment with a targetActor " +
+ "or window first"
+ );
+ }
+ const win = this._targetActor ? this._targetActor.window : this._win;
+
+ try {
+ return Cu.isDeadWrapper(win) ? null : win;
+ } catch (e) {
+ // win is null
+ return null;
+ }
+ }
+
+ get document() {
+ return this.window && this.window.document;
+ }
+
+ get docShell() {
+ return this.window && this.window.docShell;
+ }
+
+ get webProgress() {
+ return (
+ this.docShell &&
+ this.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress)
+ );
+ }
+
+ /**
+ * Get the right target for listening to events on the page.
+ * - If the environment was initialized from a WindowGlobalTargetActor
+ * *and* if we're in the Browser Toolbox (to inspect Firefox Desktop): the
+ * targetActor is the RootActor, in which case, the window property can be
+ * used to listen to events.
+ * - With Firefox Desktop, the targetActor is a WindowGlobalTargetActor, and we use
+ * the chromeEventHandler which gives us a target we can use to listen to
+ * events, even from nested iframes.
+ * - If the environment was initialized from a window, we also use the
+ * chromeEventHandler.
+ */
+ get pageListenerTarget() {
+ if (this._targetActor && this._targetActor.isRootActor) {
+ return this.window;
+ }
+ return this.docShell && this.docShell.chromeEventHandler;
+ }
+
+ relayTargetEvent(name, data) {
+ this.emit(name, data);
+ }
+
+ destroy() {
+ if (this._abortController) {
+ this._abortController.abort();
+ this._abortController = null;
+ }
+
+ // In case the environment was initialized from a window, we need to remove
+ // the progress listener.
+ if (this._win) {
+ try {
+ this.webProgress.removeProgressListener(this.listener);
+ } catch (e) {
+ // Which may fail in case the window was already destroyed.
+ }
+ }
+
+ this._targetActor = null;
+ this._win = null;
+ }
+}
+exports.HighlighterEnvironment = HighlighterEnvironment;
+
+// This constant object is created to make the calls array more
+// readable. Otherwise, linting rules force some array defs to span 4
+// lines instead, which is much harder to parse.
+const HIGHLIGHTERS = {
+ accessible: "devtools/server/actors/highlighters/accessible",
+ boxModel: "devtools/server/actors/highlighters/box-model",
+ cssGrid: "devtools/server/actors/highlighters/css-grid",
+ cssTransform: "devtools/server/actors/highlighters/css-transform",
+ eyeDropper: "devtools/server/actors/highlighters/eye-dropper",
+ flexbox: "devtools/server/actors/highlighters/flexbox",
+ fonts: "devtools/server/actors/highlighters/fonts",
+ geometryEditor: "devtools/server/actors/highlighters/geometry-editor",
+ measuringTool: "devtools/server/actors/highlighters/measuring-tool",
+ pausedDebugger: "devtools/server/actors/highlighters/paused-debugger",
+ rulers: "devtools/server/actors/highlighters/rulers",
+ selector: "devtools/server/actors/highlighters/selector",
+ shapes: "devtools/server/actors/highlighters/shapes",
+ tabbingOrder: "devtools/server/actors/highlighters/tabbing-order",
+ viewportSize: "devtools/server/actors/highlighters/viewport-size",
+};
+
+// Each array in this array is called as register(arr[0], arr[1]).
+const registerCalls = [
+ ["AccessibleHighlighter", HIGHLIGHTERS.accessible],
+ ["BoxModelHighlighter", HIGHLIGHTERS.boxModel],
+ ["CssGridHighlighter", HIGHLIGHTERS.cssGrid],
+ ["CssTransformHighlighter", HIGHLIGHTERS.cssTransform],
+ ["EyeDropper", HIGHLIGHTERS.eyeDropper],
+ ["FlexboxHighlighter", HIGHLIGHTERS.flexbox],
+ ["FontsHighlighter", HIGHLIGHTERS.fonts],
+ ["GeometryEditorHighlighter", HIGHLIGHTERS.geometryEditor],
+ ["MeasuringToolHighlighter", HIGHLIGHTERS.measuringTool],
+ ["PausedDebuggerOverlay", HIGHLIGHTERS.pausedDebugger],
+ ["RulersHighlighter", HIGHLIGHTERS.rulers],
+ ["SelectorHighlighter", HIGHLIGHTERS.selector],
+ ["ShapesHighlighter", HIGHLIGHTERS.shapes],
+ ["TabbingOrderHighlighter", HIGHLIGHTERS.tabbingOrder],
+ ["ViewportSizeHighlighter", HIGHLIGHTERS.viewportSize],
+];
+
+// Register each highlighter above.
+registerCalls.forEach(arr => {
+ registerHighlighter(arr[0], arr[1]);
+});
diff --git a/devtools/server/actors/highlighters/accessible.js b/devtools/server/actors/highlighters/accessible.js
new file mode 100644
index 0000000000..71124239f2
--- /dev/null
+++ b/devtools/server/actors/highlighters/accessible.js
@@ -0,0 +1,395 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ isNodeValid,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ TEXT_NODE,
+ DOCUMENT_NODE,
+} = require("resource://devtools/shared/dom-node-constants.js");
+const {
+ getCurrentZoom,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["getBounds", "getBoundsXUL", "Infobar"],
+ "resource://devtools/server/actors/highlighters/utils/accessibility.js",
+ true
+);
+
+/**
+ * The AccessibleHighlighter draws the bounds of an accessible object.
+ *
+ * Usage example:
+ *
+ * let h = new AccessibleHighlighter(env);
+ * h.show(node, { x, y, w, h, [duration] });
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {Number} options.x
+ * X coordinate of the top left corner of the accessible object
+ * @param {Number} options.y
+ * Y coordinate of the top left corner of the accessible object
+ * @param {Number} options.w
+ * Width of the the accessible object
+ * @param {Number} options.h
+ * Height of the the accessible object
+ * @param {Number} options.duration
+ * Duration of time that the highlighter should be shown.
+ * @param {String|null} options.name
+ * Name of the the accessible object
+ * @param {String} options.role
+ * Role of the the accessible object
+ *
+ * Structure:
+ * <div class="highlighter-container" aria-hidden="true">
+ * <div class="accessible-root">
+ * <svg class="accessible-elements" hidden="true">
+ * <path class="accessible-bounds" points="..." />
+ * </svg>
+ * <div class="accessible-infobar-container">
+ * <div class="accessible-infobar">
+ * <div class="accessible-infobar-text">
+ * <span class="accessible-infobar-role">Accessible Role</span>
+ * <span class="accessible-infobar-name">Accessible Name</span>
+ * </div>
+ * </div>
+ * </div>
+ * </div>
+ * </div>
+ */
+class AccessibleHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+ this.ID_CLASS_PREFIX = "accessible-";
+ this.accessibleInfobar = new Infobar(this);
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ this.pageListenerTarget = highlighterEnv.pageListenerTarget;
+ this.pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ /**
+ * Static getter that indicates that AccessibleHighlighter supports
+ * highlighting in XUL windows.
+ */
+ static get XULSupported() {
+ return true;
+ }
+
+ get supportsSimpleHighlighters() {
+ return true;
+ }
+
+ /**
+ * Build highlighter markup.
+ *
+ * @return {Object} Container element for the highlighter markup.
+ */
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ "aria-hidden": "true",
+ },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class:
+ "root" +
+ (this.highlighterEnv.useSimpleHighlightersForReducedMotion
+ ? " use-simple-highlighters"
+ : ""),
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the SVG element.
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: svg,
+ attributes: {
+ class: "bounds",
+ id: "bounds",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the accessible's infobar markup.
+ this.accessibleInfobar.buildMarkup(root);
+
+ return container;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ if (this._highlightTimer) {
+ clearTimeout(this._highlightTimer);
+ this._highlightTimer = null;
+ }
+
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+ this.pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ this.pageListenerTarget = null;
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+
+ this.accessibleInfobar.destroy();
+ this.accessibleInfobar = null;
+ this.markup.destroy();
+ }
+
+ /**
+ * Find an element in highlighter markup.
+ *
+ * @param {String} id
+ * Highlighter markup elemet id attribute.
+ * @return {DOMNode} Element in the highlighter markup.
+ */
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Check if node is a valid element, document or text node.
+ *
+ * @override AutoRefreshHighlighter.prototype._isNodeValid
+ * @param {DOMNode} node
+ * The node to highlight.
+ * @return {Boolean} whether or not node is valid.
+ */
+ _isNodeValid(node) {
+ return (
+ super._isNodeValid(node) ||
+ isNodeValid(node, TEXT_NODE) ||
+ isNodeValid(node, DOCUMENT_NODE)
+ );
+ }
+
+ /**
+ * Show the highlighter on a given accessible.
+ *
+ * @return {Boolean} True if accessible is highlighted, false otherwise.
+ */
+ _show() {
+ if (this._highlightTimer) {
+ clearTimeout(this._highlightTimer);
+ this._highlightTimer = null;
+ }
+
+ const { duration } = this.options;
+ const shown = this._update();
+ if (shown) {
+ this.emit("highlighter-event", { options: this.options, type: "shown" });
+ if (duration) {
+ this._highlightTimer = setTimeout(() => {
+ this.hide();
+ }, duration);
+ }
+ }
+
+ return shown;
+ }
+
+ /**
+ * Update and show accessible bounds for a current accessible.
+ *
+ * @return {Boolean} True if accessible is highlighted, false otherwise.
+ */
+ _update() {
+ let shown = false;
+ setIgnoreLayoutChanges(true);
+
+ if (this._updateAccessibleBounds()) {
+ this._showAccessibleBounds();
+
+ this.accessibleInfobar.show();
+
+ shown = true;
+ } else {
+ // Nothing to highlight (0px rectangle like a <script> tag for instance)
+ this.hide();
+ }
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+
+ return shown;
+ }
+
+ /**
+ * Hide the highlighter.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+ this._hideAccessibleBounds();
+ this.accessibleInfobar.hide();
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Public API method to temporarily hide accessible bounds for things like
+ * color contrast calculation.
+ */
+ hideAccessibleBounds() {
+ if (this.getElement("elements").hasAttribute("hidden")) {
+ return;
+ }
+
+ this._hideAccessibleBounds();
+ this._shouldRestoreBoundsVisibility = true;
+ }
+
+ /**
+ * Public API method to show accessible bounds in case they were temporarily
+ * hidden.
+ */
+ showAccessibleBounds() {
+ if (this._shouldRestoreBoundsVisibility) {
+ this._showAccessibleBounds();
+ }
+ }
+
+ /**
+ * Hide the accessible bounds container.
+ */
+ _hideAccessibleBounds() {
+ this._shouldRestoreBoundsVisibility = null;
+ setIgnoreLayoutChanges(true);
+ this.getElement("elements").setAttribute("hidden", "true");
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Show the accessible bounds container.
+ */
+ _showAccessibleBounds() {
+ this._shouldRestoreBoundsVisibility = null;
+ if (!this.currentNode || !this.highlighterEnv.window) {
+ return;
+ }
+
+ setIgnoreLayoutChanges(true);
+ this.getElement("elements").removeAttribute("hidden");
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Get current accessible bounds.
+ *
+ * @return {Object|null} Returns, if available, positioning and bounds
+ * information for the accessible object.
+ */
+ get _bounds() {
+ let { win, options } = this;
+ let getBoundsFn = getBounds;
+ if (this.options.isXUL) {
+ // Zoom level for the top level browser window does not change and only
+ // inner frames do. So we need to get the zoom level of the current node's
+ // parent window.
+ let zoom = getCurrentZoom(this.currentNode);
+ zoom *= zoom;
+ options = { ...options, zoom };
+ getBoundsFn = getBoundsXUL;
+ win = this.win.parent.ownerGlobal;
+ }
+
+ return getBoundsFn(win, options);
+ }
+
+ /**
+ * Update accessible bounds for a current accessible. Re-draw highlighter
+ * markup.
+ *
+ * @return {Boolean} True if accessible is highlighted, false otherwise.
+ */
+ _updateAccessibleBounds() {
+ const bounds = this._bounds;
+ if (!bounds) {
+ this._hide();
+ return false;
+ }
+
+ const boundsEl = this.getElement("bounds");
+ const { left, right, top, bottom } = bounds;
+ const path = `M${left},${top} L${right},${top} L${right},${bottom} L${left},${bottom} L${left},${top}`;
+ boundsEl.setAttribute("d", path);
+
+ // Un-zoom the root wrapper if the page was zoomed.
+ const rootId = this.ID_CLASS_PREFIX + "elements";
+ this.markup.scaleRootElement(this.currentNode, rootId);
+
+ return true;
+ }
+
+ /**
+ * Hide highlighter on page hide.
+ */
+ onPageHide({ target }) {
+ // If a pagehide event is triggered for current window's highlighter, hide
+ // the highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Hide highlighter on navigation.
+ */
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.AccessibleHighlighter = AccessibleHighlighter;
diff --git a/devtools/server/actors/highlighters/auto-refresh.js b/devtools/server/actors/highlighters/auto-refresh.js
new file mode 100644
index 0000000000..35e870e795
--- /dev/null
+++ b/devtools/server/actors/highlighters/auto-refresh.js
@@ -0,0 +1,368 @@
+/* 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 {
+ isNodeValid,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ getAdjustedQuads,
+ getWindowDimensions,
+} = require("resource://devtools/shared/layout/utils.js");
+
+// Note that the order of items in this array is important because it is used
+// for drawing the BoxModelHighlighter's path elements correctly.
+const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"];
+const QUADS_PROPS = ["p1", "p2", "p3", "p4"];
+
+function arePointsDifferent(pointA, pointB) {
+ return (
+ Math.abs(pointA.x - pointB.x) >= 0.5 ||
+ Math.abs(pointA.y - pointB.y) >= 0.5 ||
+ Math.abs(pointA.w - pointB.w) >= 0.5
+ );
+}
+
+function areQuadsDifferent(oldQuads, newQuads) {
+ for (const region of BOX_MODEL_REGIONS) {
+ const { length } = oldQuads[region];
+
+ if (length !== newQuads[region].length) {
+ return true;
+ }
+
+ for (let i = 0; i < length; i++) {
+ for (const prop of QUADS_PROPS) {
+ const oldPoint = oldQuads[region][i][prop];
+ const newPoint = newQuads[region][i][prop];
+
+ if (arePointsDifferent(oldPoint, newPoint)) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Base class for auto-refresh-on-change highlighters. Sub classes will have a
+ * chance to update whenever the current node's geometry changes.
+ *
+ * Sub classes must implement the following methods:
+ * _show: called when the highlighter should be shown,
+ * _hide: called when the highlighter should be hidden,
+ * _update: called while the highlighter is shown and the geometry of the
+ * current node changes.
+ *
+ * Sub classes will have access to the following properties:
+ * - this.currentNode: the node to be shown
+ * - this.currentQuads: all of the node's box model region quads
+ * - this.win: the current window
+ *
+ * Emits the following events:
+ * - shown
+ * - hidden
+ * - updated
+ */
+class AutoRefreshHighlighter extends EventEmitter {
+ constructor(highlighterEnv) {
+ super();
+
+ this.highlighterEnv = highlighterEnv;
+
+ this._updateSimpleHighlighters = this._updateSimpleHighlighters.bind(this);
+ this.highlighterEnv.on(
+ "use-simple-highlighters-updated",
+ this._updateSimpleHighlighters
+ );
+
+ this.currentNode = null;
+ this.currentQuads = {};
+
+ this._winDimensions = getWindowDimensions(this.win);
+ this._scroll = { x: this.win.pageXOffset, y: this.win.pageYOffset };
+
+ this.update = this.update.bind(this);
+ }
+
+ _ignoreZoom = false;
+ _ignoreScroll = false;
+
+ /**
+ * Window corresponding to the current highlighterEnv.
+ */
+ get win() {
+ if (!this.highlighterEnv) {
+ return null;
+ }
+ return this.highlighterEnv.window;
+ }
+
+ /* Window containing the target content. */
+ get contentWindow() {
+ return this.win;
+ }
+
+ get supportsSimpleHighlighters() {
+ return false;
+ }
+
+ /**
+ * Show the highlighter on a given node
+ * @param {DOMNode} node
+ * @param {Object} options
+ * Object used for passing options
+ */
+ show(node, options = {}) {
+ const isSameNode = node === this.currentNode;
+ const isSameOptions = this._isSameOptions(options);
+
+ if (!this._isNodeValid(node) || (isSameNode && isSameOptions)) {
+ return false;
+ }
+
+ this.options = options;
+
+ this._stopRefreshLoop();
+ this.currentNode = node;
+
+ // For offset-path, the highlighter needs to be computed from the containing block
+ // of the node, not the node itself.
+ this.useContainingBlock = this.options.mode === "cssOffsetPath";
+ this.drawingNode = this.useContainingBlock
+ ? InspectorUtils.containingBlockOf(this.currentNode)
+ : this.currentNode;
+
+ this._updateAdjustedQuads();
+ this._startRefreshLoop();
+
+ const shown = this._show();
+ if (shown) {
+ this.emit("shown");
+ }
+ return shown;
+ }
+
+ /**
+ * Hide the highlighter
+ */
+ hide() {
+ if (!this.currentNode || !this.highlighterEnv.window) {
+ return;
+ }
+
+ this._hide();
+ this._stopRefreshLoop();
+ this.currentNode = null;
+ this.currentQuads = {};
+ this.options = null;
+
+ this.emit("hidden");
+ }
+
+ /**
+ * Whether the current node is valid for this highlighter type.
+ * This is implemented by default to check if the node is an element node. Highlighter
+ * sub-classes should override this method if they want to highlight other node types.
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+ _isNodeValid(node) {
+ return isNodeValid(node);
+ }
+
+ /**
+ * Are the provided options the same as the currently stored options?
+ * Returns false if there are no options stored currently.
+ */
+ _isSameOptions(options) {
+ if (!this.options) {
+ return false;
+ }
+
+ const keys = Object.keys(options);
+
+ if (keys.length !== Object.keys(this.options).length) {
+ return false;
+ }
+
+ for (const key of keys) {
+ if (this.options[key] !== options[key]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Update the stored box quads by reading the current node's box quads.
+ */
+ _updateAdjustedQuads() {
+ this.currentQuads = {};
+
+ // If we need to use the containing block, and if it is the <html> element,
+ // we need to use the viewport quads.
+ const useViewport =
+ this.useContainingBlock &&
+ this.drawingNode === this.currentNode.ownerDocument.documentElement;
+ const node = useViewport
+ ? this.drawingNode.ownerDocument
+ : this.drawingNode;
+
+ for (const region of BOX_MODEL_REGIONS) {
+ this.currentQuads[region] = getAdjustedQuads(
+ this.contentWindow,
+ node,
+ region,
+ { ignoreScroll: this._ignoreScroll, ignoreZoom: this._ignoreZoom }
+ );
+ }
+ }
+
+ /**
+ * Update the knowledge we have of the current node's boxquads and return true
+ * if any of the points x/y or bounds have change since.
+ * @return {Boolean}
+ */
+ _hasMoved() {
+ const oldQuads = this.currentQuads;
+ this._updateAdjustedQuads();
+
+ return areQuadsDifferent(oldQuads, this.currentQuads);
+ }
+
+ /**
+ * Update the knowledge we have of the current window's scrolling offset, both
+ * horizontal and vertical, and return `true` if they have changed since.
+ * @return {Boolean}
+ */
+ _hasWindowScrolled() {
+ if (!this.win) {
+ return false;
+ }
+
+ const { pageXOffset, pageYOffset } = this.win;
+ const hasChanged =
+ this._scroll.x !== pageXOffset || this._scroll.y !== pageYOffset;
+
+ this._scroll = { x: pageXOffset, y: pageYOffset };
+
+ return hasChanged;
+ }
+
+ /**
+ * Update the knowledge we have of the current window's dimensions and return `true`
+ * if they have changed since.
+ * @return {Boolean}
+ */
+ _haveWindowDimensionsChanged() {
+ const { width, height } = getWindowDimensions(this.win);
+ const haveChanged =
+ this._winDimensions.width !== width ||
+ this._winDimensions.height !== height;
+
+ this._winDimensions = { width, height };
+ return haveChanged;
+ }
+
+ /**
+ * Update the highlighter if the node has moved since the last update.
+ */
+ update() {
+ if (
+ !this._isNodeValid(this.currentNode) ||
+ (!this._hasMoved() && !this._haveWindowDimensionsChanged())
+ ) {
+ // At this point we're not calling the `_update` method. However, if the window has
+ // scrolled, we want to invoke `_scrollUpdate`.
+ if (this._hasWindowScrolled()) {
+ this._scrollUpdate();
+ }
+
+ return;
+ }
+
+ this._update();
+ this.emit("updated");
+ }
+
+ _show() {
+ // To be implemented by sub classes
+ // When called, sub classes should actually show the highlighter for
+ // this.currentNode, potentially using options in this.options
+ throw new Error("Custom highlighter class had to implement _show method");
+ }
+
+ _update() {
+ // To be implemented by sub classes
+ // When called, sub classes should update the highlighter shown for
+ // this.currentNode
+ // This is called as a result of a page zoom or repaint
+ throw new Error("Custom highlighter class had to implement _update method");
+ }
+
+ _scrollUpdate() {
+ // Can be implemented by sub classes
+ // When called, sub classes can upate the highlighter shown for
+ // this.currentNode
+ // This is called as a result of a page scroll
+ }
+
+ _hide() {
+ // To be implemented by sub classes
+ // When called, sub classes should actually hide the highlighter
+ throw new Error("Custom highlighter class had to implement _hide method");
+ }
+
+ _startRefreshLoop() {
+ const win = this.currentNode.ownerGlobal;
+ this.rafID = win.requestAnimationFrame(this._startRefreshLoop.bind(this));
+ this.rafWin = win;
+ this.update();
+ }
+
+ _stopRefreshLoop() {
+ if (this.rafID && !Cu.isDeadWrapper(this.rafWin)) {
+ this.rafWin.cancelAnimationFrame(this.rafID);
+ }
+ this.rafID = this.rafWin = null;
+ }
+
+ _updateSimpleHighlighters() {
+ if (!this.supportsSimpleHighlighters) {
+ return;
+ }
+
+ const root = this.getElement("root");
+ if (!root) {
+ // Highlighters which support simple highlighters are expected to use a
+ // root element with the id "root".
+ return;
+ }
+
+ // Add/remove the `user-simple-highlighters` class based on the current
+ // toolbox configuration.
+ root.classList.toggle(
+ "use-simple-highlighters",
+ this.highlighterEnv.useSimpleHighlightersForReducedMotion
+ );
+ }
+
+ destroy() {
+ this.hide();
+
+ this.highlighterEnv.off(
+ "use-simple-highlighters-updated",
+ this._updateSimpleHighlighters
+ );
+ this.highlighterEnv = null;
+ this.currentNode = null;
+ }
+}
+exports.AutoRefreshHighlighter = AutoRefreshHighlighter;
diff --git a/devtools/server/actors/highlighters/box-model.js b/devtools/server/actors/highlighters/box-model.js
new file mode 100644
index 0000000000..9368f2f292
--- /dev/null
+++ b/devtools/server/actors/highlighters/box-model.js
@@ -0,0 +1,892 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getBindingElementAndPseudo,
+ hasPseudoClassLock,
+ isNodeValid,
+ moveInfobar,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ PSEUDO_CLASSES,
+} = require("resource://devtools/shared/css/constants.js");
+const {
+ getCurrentZoom,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ getNodeDisplayName,
+ getNodeGridFlexType,
+} = require("resource://devtools/server/actors/inspector/utils.js");
+const nodeConstants = require("resource://devtools/shared/dom-node-constants.js");
+loader.lazyGetter(this, "HighlightersBundle", () => {
+ return new Localization(["devtools/shared/highlighters.ftl"], true);
+});
+
+// Note that the order of items in this array is important because it is used
+// for drawing the BoxModelHighlighter's path elements correctly.
+const BOX_MODEL_REGIONS = ["margin", "border", "padding", "content"];
+const BOX_MODEL_SIDES = ["top", "right", "bottom", "left"];
+// Width of boxmodelhighlighter guides
+const GUIDE_STROKE_WIDTH = 1;
+
+/**
+ * The BoxModelHighlighter draws the box model regions on top of a node.
+ * If the node is a block box, then each region will be displayed as 1 polygon.
+ * If the node is an inline box though, each region may be represented by 1 or
+ * more polygons, depending on how many line boxes the inline element has.
+ *
+ * Usage example:
+ *
+ * let h = new BoxModelHighlighter(env);
+ * h.show(node, options);
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {String} options.region
+ * Specifies the region that the guides should outline:
+ * "content" (default), "padding", "border" or "margin".
+ * @param {Boolean} options.hideGuides
+ * Defaults to false
+ * @param {Boolean} options.hideInfoBar
+ * Defaults to false
+ * @param {String} options.showOnly
+ * If set, only this region will be highlighted. Use with onlyRegionArea
+ * to only highlight the area of the region:
+ * "content", "padding", "border" or "margin"
+ * @param {Boolean} options.onlyRegionArea
+ * This can be set to true to make each region's box only highlight the
+ * area of the corresponding region rather than the area of nested
+ * regions too. This is useful when used with showOnly.
+ *
+ * Structure:
+ * <div class="highlighter-container" aria-hidden="true">
+ * <div class="box-model-root">
+ * <svg class="box-model-elements" hidden="true">
+ * <g class="box-model-regions">
+ * <path class="box-model-margin" points="..." />
+ * <path class="box-model-border" points="..." />
+ * <path class="box-model-padding" points="..." />
+ * <path class="box-model-content" points="..." />
+ * </g>
+ * <line class="box-model-guide-top" x1="..." y1="..." x2="..." y2="..." />
+ * <line class="box-model-guide-right" x1="..." y1="..." x2="..." y2="..." />
+ * <line class="box-model-guide-bottom" x1="..." y1="..." x2="..." y2="..." />
+ * <line class="box-model-guide-left" x1="..." y1="..." x2="..." y2="..." />
+ * </svg>
+ * <div class="box-model-infobar-container">
+ * <div class="box-model-infobar-arrow highlighter-infobar-arrow-top" />
+ * <div class="box-model-infobar">
+ * <div class="box-model-infobar-text" align="center">
+ * <span class="box-model-infobar-tagname">Node name</span>
+ * <span class="box-model-infobar-id">Node id</span>
+ * <span class="box-model-infobar-classes">.someClass</span>
+ * <span class="box-model-infobar-pseudo-classes">:hover</span>
+ * <span class="box-model-infobar-grid-type">Grid Type</span>
+ * <span class="box-model-infobar-flex-type">Flex Type</span>
+ * </div>
+ * </div>
+ * <div class="box-model-infobar-arrow box-model-infobar-arrow-bottom"/>
+ * </div>
+ * </div>
+ * </div>
+ */
+class BoxModelHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "box-model-";
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ /**
+ * Static getter that indicates that BoxModelHighlighter supports
+ * highlighting in XUL windows.
+ */
+ static get XULSupported() {
+ return true;
+ }
+
+ get supportsSimpleHighlighters() {
+ return true;
+ }
+
+ _buildMarkup() {
+ const highlighterContainer =
+ this.markup.anonymousContentDocument.createElement("div");
+ highlighterContainer.className = "highlighter-container box-model";
+
+ this.highlighterContainer = highlighterContainer;
+ // We need a better solution for how to handle the highlighter from the
+ // accessibility standpoint. For now, in order to avoid displaying it in the
+ // accessibility tree lets hide it altogether. See bug 1598667 for more
+ // context.
+ highlighterContainer.setAttribute("aria-hidden", "true");
+
+ // Build the root wrapper, used to adapt to the page zoom.
+ const rootWrapper = this.markup.createNode({
+ parent: highlighterContainer,
+ attributes: {
+ id: "root",
+ class:
+ "root" +
+ (this.highlighterEnv.useSimpleHighlightersForReducedMotion
+ ? " use-simple-highlighters"
+ : ""),
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Building the SVG element with its polygons and lines
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const regions = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ class: "regions",
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ for (const region of BOX_MODEL_REGIONS) {
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ class: region,
+ id: region,
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ for (const side of BOX_MODEL_SIDES) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ class: "guide-" + side,
+ id: "guide-" + side,
+ "stroke-width": GUIDE_STROKE_WIDTH,
+ role: "presentation",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ // Building the nodeinfo bar markup
+
+ const infobarContainer = this.markup.createNode({
+ parent: rootWrapper,
+ attributes: {
+ class: "infobar-container",
+ id: "infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const infobar = this.markup.createNode({
+ parent: infobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const texthbox = this.markup.createNode({
+ parent: infobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-tagname",
+ id: "infobar-tagname",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-id",
+ id: "infobar-id",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-classes",
+ id: "infobar-classes",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-pseudo-classes",
+ id: "infobar-pseudo-classes",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-dimensions",
+ id: "infobar-dimensions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-grid-type",
+ id: "infobar-grid-type",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: texthbox,
+ attributes: {
+ class: "infobar-flex-type",
+ id: "infobar-flex-type",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return highlighterContainer;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = this.highlighterEnv;
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.markup.destroy();
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Override the AutoRefreshHighlighter's _isNodeValid method to also return true for
+ * text nodes since these can also be highlighted.
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+ _isNodeValid(node) {
+ return (
+ node && (isNodeValid(node) || isNodeValid(node, nodeConstants.TEXT_NODE))
+ );
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ if (!BOX_MODEL_REGIONS.includes(this.options.region)) {
+ this.options.region = "content";
+ }
+
+ const shown = this._update();
+ this._trackMutations();
+ return shown;
+ }
+
+ /**
+ * Track the current node markup mutations so that the node info bar can be
+ * updated to reflects the node's attributes
+ */
+ _trackMutations() {
+ if (isNodeValid(this.currentNode)) {
+ const win = this.currentNode.ownerGlobal;
+ this.currentNodeObserver = new win.MutationObserver(this.update);
+ this.currentNodeObserver.observe(this.currentNode, { attributes: true });
+ }
+ }
+
+ _untrackMutations() {
+ if (isNodeValid(this.currentNode) && this.currentNodeObserver) {
+ this.currentNodeObserver.disconnect();
+ this.currentNodeObserver = null;
+ }
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)).
+ * Should be called whenever node size or attributes change
+ */
+ _update() {
+ const node = this.currentNode;
+ let shown = false;
+ setIgnoreLayoutChanges(true);
+
+ if (this._updateBoxModel()) {
+ // Show the infobar only if configured to do so and the node is an element or a text
+ // node.
+ if (
+ !this.options.hideInfoBar &&
+ (node.nodeType === node.ELEMENT_NODE ||
+ node.nodeType === node.TEXT_NODE)
+ ) {
+ this._showInfobar();
+ } else {
+ this._hideInfobar();
+ }
+ this._updateSimpleHighlighters();
+ this._showBoxModel();
+
+ shown = true;
+ } else {
+ // Nothing to highlight (0px rectangle like a <script> tag for instance)
+ this._hide();
+ }
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+
+ return shown;
+ }
+
+ _scrollUpdate() {
+ this._moveInfobar();
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this._untrackMutations();
+ this._hideBoxModel();
+ this._hideInfobar();
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Hide the infobar
+ */
+ _hideInfobar() {
+ this.getElement("infobar-container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the infobar
+ */
+ _showInfobar() {
+ this.getElement("infobar-container").removeAttribute("hidden");
+ this._updateInfobar();
+ }
+
+ /**
+ * Hide the box model
+ */
+ _hideBoxModel() {
+ this.getElement("elements").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the box model
+ */
+ _showBoxModel() {
+ this.getElement("elements").removeAttribute("hidden");
+ }
+
+ /**
+ * Calculate an outer quad based on the quads returned by getAdjustedQuads.
+ * The BoxModelHighlighter may highlight more than one boxes, so in this case
+ * create a new quad that "contains" all of these quads.
+ * This is useful to position the guides and infobar.
+ * This may happen if the BoxModelHighlighter is used to highlight an inline
+ * element that spans line breaks.
+ * @param {String} region The box-model region to get the outer quad for.
+ * @return {Object} A quad-like object {p1,p2,p3,p4,bounds}
+ */
+ _getOuterQuad(region) {
+ const quads = this.currentQuads[region];
+ if (!quads || !quads.length) {
+ return null;
+ }
+
+ const quad = {
+ p1: { x: Infinity, y: Infinity },
+ p2: { x: -Infinity, y: Infinity },
+ p3: { x: -Infinity, y: -Infinity },
+ p4: { x: Infinity, y: -Infinity },
+ bounds: {
+ bottom: -Infinity,
+ height: 0,
+ left: Infinity,
+ right: -Infinity,
+ top: Infinity,
+ width: 0,
+ x: 0,
+ y: 0,
+ },
+ };
+
+ for (const q of quads) {
+ quad.p1.x = Math.min(quad.p1.x, q.p1.x);
+ quad.p1.y = Math.min(quad.p1.y, q.p1.y);
+ quad.p2.x = Math.max(quad.p2.x, q.p2.x);
+ quad.p2.y = Math.min(quad.p2.y, q.p2.y);
+ quad.p3.x = Math.max(quad.p3.x, q.p3.x);
+ quad.p3.y = Math.max(quad.p3.y, q.p3.y);
+ quad.p4.x = Math.min(quad.p4.x, q.p4.x);
+ quad.p4.y = Math.max(quad.p4.y, q.p4.y);
+
+ quad.bounds.bottom = Math.max(quad.bounds.bottom, q.bounds.bottom);
+ quad.bounds.top = Math.min(quad.bounds.top, q.bounds.top);
+ quad.bounds.left = Math.min(quad.bounds.left, q.bounds.left);
+ quad.bounds.right = Math.max(quad.bounds.right, q.bounds.right);
+ }
+ quad.bounds.x = quad.bounds.left;
+ quad.bounds.y = quad.bounds.top;
+ quad.bounds.width = quad.bounds.right - quad.bounds.left;
+ quad.bounds.height = quad.bounds.bottom - quad.bounds.top;
+
+ return quad;
+ }
+
+ /**
+ * Update the box model as per the current node.
+ *
+ * @return {boolean}
+ * True if the current node has a box model to be highlighted
+ */
+ _updateBoxModel() {
+ const options = this.options;
+ options.region = options.region || "content";
+
+ if (!this._nodeNeedsHighlighting()) {
+ this._hideBoxModel();
+ return false;
+ }
+
+ for (let i = 0; i < BOX_MODEL_REGIONS.length; i++) {
+ const boxType = BOX_MODEL_REGIONS[i];
+ const nextBoxType = BOX_MODEL_REGIONS[i + 1];
+ const box = this.getElement(boxType);
+
+ // Highlight all quads for this region by setting the "d" attribute of the
+ // corresponding <path>.
+ const path = [];
+ for (let j = 0; j < this.currentQuads[boxType].length; j++) {
+ const boxQuad = this.currentQuads[boxType][j];
+ const nextBoxQuad = this.currentQuads[nextBoxType]
+ ? this.currentQuads[nextBoxType][j]
+ : null;
+ path.push(this._getBoxPathCoordinates(boxQuad, nextBoxQuad));
+ }
+
+ box.setAttribute("d", path.join(" "));
+ box.removeAttribute("faded");
+
+ // If showOnly is defined, either hide the other regions, or fade them out
+ // if onlyRegionArea is set too.
+ if (options.showOnly && options.showOnly !== boxType) {
+ if (options.onlyRegionArea) {
+ box.setAttribute("faded", "true");
+ } else {
+ box.removeAttribute("d");
+ }
+ }
+
+ if (boxType === options.region && !options.hideGuides) {
+ this._showGuides(boxType);
+ } else if (options.hideGuides) {
+ this._hideGuides();
+ }
+ }
+
+ // Un-zoom the root wrapper if the page was zoomed.
+ const rootId = this.ID_CLASS_PREFIX + "elements";
+ this.markup.scaleRootElement(this.currentNode, rootId);
+
+ return true;
+ }
+
+ _getBoxPathCoordinates(boxQuad, nextBoxQuad) {
+ const { p1, p2, p3, p4 } = boxQuad;
+
+ let path;
+ if (!nextBoxQuad || !this.options.onlyRegionArea) {
+ // If this is the content box (inner-most box) or if we're not being asked
+ // to highlight only region areas, then draw a simple rectangle.
+ path =
+ "M" +
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ "L" +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ "L" +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ "L" +
+ p4.x +
+ "," +
+ p4.y +
+ " " +
+ "L" +
+ p1.x +
+ "," +
+ p1.y;
+ } else {
+ // Otherwise, just draw the region itself, not a filled rectangle.
+ const { p1: np1, p2: np2, p3: np3, p4: np4 } = nextBoxQuad;
+ path =
+ "M" +
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ "L" +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ "L" +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ "L" +
+ p4.x +
+ "," +
+ p4.y +
+ " " +
+ "L" +
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ "L" +
+ np1.x +
+ "," +
+ np1.y +
+ " " +
+ "L" +
+ np4.x +
+ "," +
+ np4.y +
+ " " +
+ "L" +
+ np3.x +
+ "," +
+ np3.y +
+ " " +
+ "L" +
+ np2.x +
+ "," +
+ np2.y +
+ " " +
+ "L" +
+ np1.x +
+ "," +
+ np1.y;
+ }
+
+ return path;
+ }
+
+ /**
+ * Can the current node be highlighted? Does it have quads.
+ * @return {Boolean}
+ */
+ _nodeNeedsHighlighting() {
+ return (
+ this.currentQuads.margin.length ||
+ this.currentQuads.border.length ||
+ this.currentQuads.padding.length ||
+ this.currentQuads.content.length
+ );
+ }
+
+ _getOuterBounds() {
+ for (const region of ["margin", "border", "padding", "content"]) {
+ const quad = this._getOuterQuad(region);
+
+ if (!quad) {
+ // Invisible element such as a script tag.
+ break;
+ }
+
+ const { bottom, height, left, right, top, width, x, y } = quad.bounds;
+
+ if (width > 0 || height > 0) {
+ return { bottom, height, left, right, top, width, x, y };
+ }
+ }
+
+ return {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+ }
+
+ /**
+ * We only want to show guides for horizontal and vertical edges as this helps
+ * to line them up. This method finds these edges and displays a guide there.
+ * @param {String} region The region around which the guides should be shown.
+ */
+ _showGuides(region) {
+ const quad = this._getOuterQuad(region);
+
+ if (!quad) {
+ // Invisible element such as a script tag.
+ return;
+ }
+
+ const { p1, p2, p3, p4 } = quad;
+
+ const allX = [p1.x, p2.x, p3.x, p4.x].sort((a, b) => a - b);
+ const allY = [p1.y, p2.y, p3.y, p4.y].sort((a, b) => a - b);
+ const toShowX = [];
+ const toShowY = [];
+
+ for (const arr of [allX, allY]) {
+ for (let i = 0; i < arr.length; i++) {
+ const val = arr[i];
+
+ if (i !== arr.lastIndexOf(val)) {
+ if (arr === allX) {
+ toShowX.push(val);
+ } else {
+ toShowY.push(val);
+ }
+ arr.splice(arr.lastIndexOf(val), 1);
+ }
+ }
+ }
+
+ // Move guide into place or hide it if no valid co-ordinate was found.
+ this._updateGuide("top", Math.round(toShowY[0]));
+ this._updateGuide("right", Math.round(toShowX[1]) - 1);
+ this._updateGuide("bottom", Math.round(toShowY[1]) - 1);
+ this._updateGuide("left", Math.round(toShowX[0]));
+ }
+
+ _hideGuides() {
+ for (const side of BOX_MODEL_SIDES) {
+ this.getElement("guide-" + side).setAttribute("hidden", "true");
+ }
+ }
+
+ /**
+ * Move a guide to the appropriate position and display it. If no point is
+ * passed then the guide is hidden.
+ *
+ * @param {String} side
+ * The guide to update
+ * @param {Integer} point
+ * x or y co-ordinate. If this is undefined we hide the guide.
+ */
+ _updateGuide(side, point) {
+ const guide = this.getElement("guide-" + side);
+
+ if (!point || point <= 0) {
+ guide.setAttribute("hidden", "true");
+ return false;
+ }
+
+ if (side === "top" || side === "bottom") {
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", point + "");
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", point + "");
+ } else {
+ guide.setAttribute("x1", point + "");
+ guide.setAttribute("y1", "0");
+ guide.setAttribute("x2", point + "");
+ guide.setAttribute("y2", "100%");
+ }
+
+ guide.removeAttribute("hidden");
+
+ return true;
+ }
+
+ /**
+ * Update node information (displayName#id.class)
+ */
+ _updateInfobar() {
+ if (!this.currentNode) {
+ return;
+ }
+
+ const { bindingElement: node, pseudo } = getBindingElementAndPseudo(
+ this.currentNode
+ );
+
+ // Update the tag, id, classes, pseudo-classes and dimensions
+ const displayName = getNodeDisplayName(node);
+
+ const id = node.id ? "#" + node.id : "";
+
+ const classList = (node.classList || []).length
+ ? "." + [...node.classList].join(".")
+ : "";
+
+ let pseudos = this._getPseudoClasses(node).join("");
+ if (pseudo) {
+ pseudos += pseudo;
+ }
+
+ // We want to display the original `width` and `height`, instead of the ones affected
+ // by any zoom. Since the infobar can be displayed also for text nodes, we can't
+ // access the computed style for that, and this is why we recalculate them here.
+ const zoom = getCurrentZoom(this.win);
+ const quad = this._getOuterQuad("border");
+
+ if (!quad) {
+ return;
+ }
+
+ const { width, height } = quad.bounds;
+ const dim =
+ parseFloat((width / zoom).toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat((height / zoom).toPrecision(6));
+
+ const { grid: gridType, flex: flexType } = getNodeGridFlexType(node);
+ const gridLayoutTextType = this._getLayoutTextType("gridtype", gridType);
+ const flexLayoutTextType = this._getLayoutTextType("flextype", flexType);
+
+ this.getElement("infobar-tagname").setTextContent(displayName);
+ this.getElement("infobar-id").setTextContent(id);
+ this.getElement("infobar-classes").setTextContent(classList);
+ this.getElement("infobar-pseudo-classes").setTextContent(pseudos);
+ this.getElement("infobar-dimensions").setTextContent(dim);
+ this.getElement("infobar-grid-type").setTextContent(gridLayoutTextType);
+ this.getElement("infobar-flex-type").setTextContent(flexLayoutTextType);
+
+ this._moveInfobar();
+ }
+
+ _getLayoutTextType(layoutTypeKey, { isContainer, isItem }) {
+ if (!isContainer && !isItem) {
+ return "";
+ }
+ if (isContainer && !isItem) {
+ return HighlightersBundle.formatValueSync(`${layoutTypeKey}-container`);
+ }
+ if (!isContainer && isItem) {
+ return HighlightersBundle.formatValueSync(`${layoutTypeKey}-item`);
+ }
+ return HighlightersBundle.formatValueSync(`${layoutTypeKey}-dual`);
+ }
+
+ _getPseudoClasses(node) {
+ if (node.nodeType !== nodeConstants.ELEMENT_NODE) {
+ // hasPseudoClassLock can only be used on Elements.
+ return [];
+ }
+
+ return PSEUDO_CLASSES.filter(pseudo => hasPseudoClassLock(node, pseudo));
+ }
+
+ /**
+ * Move the Infobar to the right place in the highlighter.
+ */
+ _moveInfobar() {
+ const bounds = this._getOuterBounds();
+ const container = this.getElement("infobar-container");
+
+ moveInfobar(container, bounds, this.win);
+ }
+
+ onPageHide({ target }) {
+ // If a pagehide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.BoxModelHighlighter = BoxModelHighlighter;
diff --git a/devtools/server/actors/highlighters/css-grid.js b/devtools/server/actors/highlighters/css-grid.js
new file mode 100644
index 0000000000..04c612eb02
--- /dev/null
+++ b/devtools/server/actors/highlighters/css-grid.js
@@ -0,0 +1,1962 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CANVAS_SIZE,
+ DEFAULT_COLOR,
+ drawBubbleRect,
+ drawLine,
+ drawRect,
+ drawRoundedRect,
+ getBoundsFromPoints,
+ getCurrentMatrix,
+ getPathDescriptionFromPoints,
+ getPointsFromDiagonal,
+ updateCanvasElement,
+ updateCanvasPosition,
+} = require("resource://devtools/server/actors/highlighters/utils/canvas.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+ moveInfobar,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const { apply } = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const {
+ getCurrentZoom,
+ getDisplayPixelRatio,
+ getWindowDimensions,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+loader.lazyGetter(this, "HighlightersBundle", () => {
+ return new Localization(["devtools/shared/highlighters.ftl"], true);
+});
+
+const COLUMNS = "cols";
+const ROWS = "rows";
+
+const GRID_FONT_SIZE = 10;
+const GRID_FONT_FAMILY = "sans-serif";
+const GRID_AREA_NAME_FONT_SIZE = "20";
+
+const GRID_LINES_PROPERTIES = {
+ edge: {
+ lineDash: [0, 0],
+ alpha: 1,
+ },
+ explicit: {
+ lineDash: [5, 3],
+ alpha: 0.75,
+ },
+ implicit: {
+ lineDash: [2, 2],
+ alpha: 0.5,
+ },
+ areaEdge: {
+ lineDash: [0, 0],
+ alpha: 1,
+ lineWidth: 3,
+ },
+};
+
+const GRID_GAP_PATTERN_WIDTH = 14; // px
+const GRID_GAP_PATTERN_HEIGHT = 14; // px
+const GRID_GAP_PATTERN_LINE_DASH = [5, 3]; // px
+const GRID_GAP_ALPHA = 0.5;
+
+// This is the minimum distance a line can be to the edge of the document under which we
+// push the line number arrow to be inside the grid. This offset is enough to fit the
+// entire arrow + a stacked arrow behind it.
+const OFFSET_FROM_EDGE = 32;
+// This is how much inside the grid we push the arrow. This a factor of the arrow size.
+// The goal here is for a row and a column arrow that have both been pushed inside the
+// grid, in a corner, not to overlap.
+const FLIP_ARROW_INSIDE_FACTOR = 2.5;
+
+/**
+ * Given an `edge` of a box, return the name of the edge one move to the right.
+ */
+function rotateEdgeRight(edge) {
+ switch (edge) {
+ case "top":
+ return "right";
+ case "right":
+ return "bottom";
+ case "bottom":
+ return "left";
+ case "left":
+ return "top";
+ default:
+ return edge;
+ }
+}
+
+/**
+ * Given an `edge` of a box, return the name of the edge one move to the left.
+ */
+function rotateEdgeLeft(edge) {
+ switch (edge) {
+ case "top":
+ return "left";
+ case "right":
+ return "top";
+ case "bottom":
+ return "right";
+ case "left":
+ return "bottom";
+ default:
+ return edge;
+ }
+}
+
+/**
+ * Given an `edge` of a box, return the name of the opposite edge.
+ */
+function reflectEdge(edge) {
+ switch (edge) {
+ case "top":
+ return "bottom";
+ case "right":
+ return "left";
+ case "bottom":
+ return "top";
+ case "left":
+ return "right";
+ default:
+ return edge;
+ }
+}
+
+/**
+ * Cached used by `CssGridHighlighter.getGridGapPattern`.
+ */
+const gCachedGridPattern = new Map();
+
+/**
+ * The CssGridHighlighter is the class that overlays a visual grid on top of
+ * display:[inline-]grid elements.
+ *
+ * Usage example:
+ * let h = new CssGridHighlighter(env);
+ * h.show(node, options);
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {String} options.color
+ * The color that should be used to draw the highlighter for this grid.
+ * @param {Number} options.globalAlpha
+ * The alpha (transparency) value that should be used to draw the highlighter for
+ * this grid.
+ * @param {Boolean} options.showAllGridAreas
+ * Shows all the grid area highlights for the current grid if isShown is
+ * true.
+ * @param {String} options.showGridArea
+ * Shows the grid area highlight for the given area name.
+ * @param {Boolean} options.showGridAreasOverlay
+ * Displays an overlay of all the grid areas for the current grid
+ * container if isShown is true.
+ * @param {Object} options.showGridCell
+ * An object containing the grid fragment index, row and column numbers
+ * to the corresponding grid cell to highlight for the current grid.
+ * @param {Number} options.showGridCell.gridFragmentIndex
+ * Index of the grid fragment to render the grid cell highlight.
+ * @param {Number} options.showGridCell.rowNumber
+ * Row number of the grid cell to highlight.
+ * @param {Number} options.showGridCell.columnNumber
+ * Column number of the grid cell to highlight.
+ * @param {Object} options.showGridLineNames
+ * An object containing the grid fragment index and line number to the
+ * corresponding grid line to highlight for the current grid.
+ * @param {Number} options.showGridLineNames.gridFragmentIndex
+ * Index of the grid fragment to render the grid line highlight.
+ * @param {Number} options.showGridLineNames.lineNumber
+ * Line number of the grid line to highlight.
+ * @param {String} options.showGridLineNames.type
+ * The dimension type of the grid line.
+ * @param {Boolean} options.showGridLineNumbers
+ * Displays the grid line numbers on the grid lines if isShown is true.
+ * @param {Boolean} options.showInfiniteLines
+ * Displays an infinite line to represent the grid lines if isShown is
+ * true.
+ * @param {Number} options.zIndex
+ * The z-index to decide the displaying order.
+ *
+ * Structure:
+ * <div class="highlighter-container">
+ * <canvas id="css-grid-canvas" class="css-grid-canvas">
+ * <svg class="css-grid-elements" hidden="true">
+ * <g class="css-grid-regions">
+ * <path class="css-grid-areas" points="..." />
+ * <path class="css-grid-cells" points="..." />
+ * </g>
+ * </svg>
+ * <div class="css-grid-area-infobar-container">
+ * <div class="css-grid-infobar">
+ * <div class="css-grid-infobar-text">
+ * <span class="css-grid-area-infobar-name">Grid Area Name</span>
+ * <span class="css-grid-area-infobar-dimensions">Grid Area Dimensions></span>
+ * </div>
+ * </div>
+ * </div>
+ * <div class="css-grid-cell-infobar-container">
+ * <div class="css-grid-infobar">
+ * <div class="css-grid-infobar-text">
+ * <span class="css-grid-cell-infobar-position">Grid Cell Position</span>
+ * <span class="css-grid-cell-infobar-dimensions">Grid Cell Dimensions></span>
+ * </div>
+ * </div>
+ * <div class="css-grid-line-infobar-container">
+ * <div class="css-grid-infobar">
+ * <div class="css-grid-infobar-text">
+ * <span class="css-grid-line-infobar-number">Grid Line Number</span>
+ * <span class="css-grid-line-infobar-names">Grid Line Names></span>
+ * </div>
+ * </div>
+ * </div>
+ * </div>
+ */
+
+class CssGridHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "css-grid-";
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+
+ // Initialize the <canvas> position to the top left corner of the page.
+ this._canvasPosition = {
+ x: 0,
+ y: 0,
+ };
+
+ // Calling `updateCanvasPosition` anyway since the highlighter could be initialized
+ // on a page that has scrolled already.
+ updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // We use a <canvas> element so that we can draw an arbitrary number of lines
+ // which wouldn't be possible with HTML or SVG without having to insert and remove
+ // the whole markup on every update.
+ this.markup.createNode({
+ parent: root,
+ nodeType: "canvas",
+ attributes: {
+ id: "canvas",
+ class: "canvas",
+ hidden: "true",
+ width: CANVAS_SIZE,
+ height: CANVAS_SIZE,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the SVG element.
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const regions = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ class: "regions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ class: "areas",
+ id: "areas",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: regions,
+ attributes: {
+ class: "cells",
+ id: "cells",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the grid area infobar markup.
+ const areaInfobarContainer = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "area-infobar-container",
+ id: "area-infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const areaInfobar = this.markup.createNode({
+ parent: areaInfobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const areaTextbox = this.markup.createNode({
+ parent: areaInfobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: areaTextbox,
+ attributes: {
+ class: "area-infobar-name",
+ id: "area-infobar-name",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: areaTextbox,
+ attributes: {
+ class: "area-infobar-dimensions",
+ id: "area-infobar-dimensions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the grid cell infobar markup.
+ const cellInfobarContainer = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "cell-infobar-container",
+ id: "cell-infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const cellInfobar = this.markup.createNode({
+ parent: cellInfobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const cellTextbox = this.markup.createNode({
+ parent: cellInfobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: cellTextbox,
+ attributes: {
+ class: "cell-infobar-position",
+ id: "cell-infobar-position",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: cellTextbox,
+ attributes: {
+ class: "cell-infobar-dimensions",
+ id: "cell-infobar-dimensions",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the grid line infobar markup.
+ const lineInfobarContainer = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "line-infobar-container",
+ id: "line-infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const lineInfobar = this.markup.createNode({
+ parent: lineInfobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const lineTextbox = this.markup.createNode({
+ parent: lineInfobar,
+ attributes: {
+ class: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: lineTextbox,
+ attributes: {
+ class: "line-infobar-number",
+ id: "line-infobar-number",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "span",
+ parent: lineTextbox,
+ attributes: {
+ class: "line-infobar-names",
+ id: "line-infobar-names",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ clearCache() {
+ gCachedGridPattern.clear();
+ }
+
+ /**
+ * Clear the grid area highlights.
+ */
+ clearGridAreas() {
+ const areas = this.getElement("areas");
+ areas.setAttribute("d", "");
+ }
+
+ /**
+ * Clear the grid cell highlights.
+ */
+ clearGridCell() {
+ const cells = this.getElement("cells");
+ cells.setAttribute("d", "");
+ }
+
+ destroy() {
+ const { highlighterEnv } = this;
+ highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.markup.destroy();
+
+ // Clear the pattern cache to avoid dead object exceptions (Bug 1342051).
+ this.clearCache();
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ get canvas() {
+ return this.getElement("canvas");
+ }
+
+ get color() {
+ return this.options.color || DEFAULT_COLOR;
+ }
+
+ get ctx() {
+ return this.canvas.getCanvasContext("2d");
+ }
+
+ get globalAlpha() {
+ return this.options.globalAlpha || 1;
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ getFirstColLinePos(fragment) {
+ return fragment.cols.lines[0].start;
+ }
+
+ getFirstRowLinePos(fragment) {
+ return fragment.rows.lines[0].start;
+ }
+
+ /**
+ * Gets the grid gap pattern used to render the gap regions based on the device
+ * pixel ratio given.
+ *
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio we want the pattern for.
+ * @param {Object} dimension
+ * Refers to the Map key for the grid dimension type which is either the
+ * constant COLUMNS or ROWS.
+ * @return {CanvasPattern} grid gap pattern.
+ */
+ getGridGapPattern(devicePixelRatio, dimension) {
+ let gridPatternMap = null;
+
+ if (gCachedGridPattern.has(devicePixelRatio)) {
+ gridPatternMap = gCachedGridPattern.get(devicePixelRatio);
+ } else {
+ gridPatternMap = new Map();
+ }
+
+ if (gridPatternMap.has(dimension)) {
+ return gridPatternMap.get(dimension);
+ }
+
+ // Create the diagonal lines pattern for the rendering the grid gaps.
+ const canvas = this.markup.createNode({ nodeType: "canvas" });
+ const width = (canvas.width = GRID_GAP_PATTERN_WIDTH * devicePixelRatio);
+ const height = (canvas.height = GRID_GAP_PATTERN_HEIGHT * devicePixelRatio);
+
+ const ctx = canvas.getContext("2d");
+ ctx.save();
+ ctx.setLineDash(GRID_GAP_PATTERN_LINE_DASH);
+ ctx.beginPath();
+ ctx.translate(0.5, 0.5);
+
+ if (dimension === COLUMNS) {
+ ctx.moveTo(0, 0);
+ ctx.lineTo(width, height);
+ } else {
+ ctx.moveTo(width, 0);
+ ctx.lineTo(0, height);
+ }
+
+ ctx.strokeStyle = this.color;
+ ctx.globalAlpha = GRID_GAP_ALPHA * this.globalAlpha;
+ ctx.stroke();
+ ctx.restore();
+
+ const pattern = ctx.createPattern(canvas, "repeat");
+
+ gridPatternMap.set(dimension, pattern);
+ gCachedGridPattern.set(devicePixelRatio, gridPatternMap);
+
+ return pattern;
+ }
+
+ getLastColLinePos(fragment) {
+ return fragment.cols.lines[fragment.cols.lines.length - 1].start;
+ }
+
+ /**
+ * Get the GridLine index of the last edge of the explicit grid for a grid dimension.
+ *
+ * @param {GridTracks} tracks
+ * The grid track of a given grid dimension.
+ * @return {Number} index of the last edge of the explicit grid for a grid dimension.
+ */
+ getLastEdgeLineIndex(tracks) {
+ let trackIndex = tracks.length - 1;
+
+ // Traverse the grid track backwards until we find an explicit track.
+ while (trackIndex >= 0 && tracks[trackIndex].type != "explicit") {
+ trackIndex--;
+ }
+
+ // The grid line index is the grid track index + 1.
+ return trackIndex + 1;
+ }
+
+ getLastRowLinePos(fragment) {
+ return fragment.rows.lines[fragment.rows.lines.length - 1].start;
+ }
+
+ /**
+ * The AutoRefreshHighlighter's _hasMoved method returns true only if the
+ * element's quads have changed. Override it so it also returns true if the
+ * element's grid has changed (which can happen when you change the
+ * grid-template-* CSS properties with the highlighter displayed). This
+ * check is prone to false positives, because it does a direct object
+ * comparison of the first grid fragment structure. This structure is
+ * generated by the first call to getGridFragments, and on any subsequent
+ * calls where a reflow is needed. Since a reflow is needed when the CSS
+ * changes, this will correctly detect that the grid structure has changed.
+ * However, it's possible that the reflow could generate a novel grid
+ * fragment object containing information that is unchanged -- a false
+ * positive.
+ */
+ _hasMoved() {
+ const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
+
+ const oldFirstGridFragment = this.gridData?.[0];
+ this.gridData = this.currentNode.getGridFragments();
+ const newFirstGridFragment = this.gridData[0];
+
+ return hasMoved || oldFirstGridFragment !== newFirstGridFragment;
+ }
+
+ /**
+ * Hide the highlighter, the canvas and the infobars.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+ this._hideGrid();
+ this._hideGridElements();
+ this._hideGridAreaInfoBar();
+ this._hideGridCellInfoBar();
+ this._hideGridLineInfoBar();
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ }
+
+ _hideGrid() {
+ this.getElement("canvas").setAttribute("hidden", "true");
+ }
+
+ _hideGridAreaInfoBar() {
+ this.getElement("area-infobar-container").setAttribute("hidden", "true");
+ }
+
+ _hideGridCellInfoBar() {
+ this.getElement("cell-infobar-container").setAttribute("hidden", "true");
+ }
+
+ _hideGridElements() {
+ this.getElement("elements").setAttribute("hidden", "true");
+ }
+
+ _hideGridLineInfoBar() {
+ this.getElement("line-infobar-container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Checks if the current node has a CSS Grid layout.
+ *
+ * @return {Boolean} true if the current node has a CSS grid layout, false otherwise.
+ */
+ isGrid() {
+ return this.currentNode.hasGridFragments();
+ }
+
+ /**
+ * Is a given grid fragment valid? i.e. does it actually have tracks? In some cases, we
+ * may have a fragment that defines column tracks but doesn't have any rows (or vice
+ * versa). In which case we do not want to draw anything for that fragment.
+ *
+ * @param {Object} fragment
+ * @return {Boolean}
+ */
+ isValidFragment(fragment) {
+ return fragment.cols.tracks.length && fragment.rows.tracks.length;
+ }
+
+ /**
+ * The <canvas>'s position needs to be updated if the page scrolls too much, in order
+ * to give the illusion that it always covers the viewport.
+ */
+ _scrollUpdate() {
+ const hasUpdated = updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+
+ if (hasUpdated) {
+ this._update();
+ }
+ }
+
+ _show() {
+ if (!this.isGrid()) {
+ this.hide();
+ return false;
+ }
+
+ // The grid pattern cache should be cleared in case the color changed.
+ this.clearCache();
+
+ // Hide the canvas, grid element highlights and infobar.
+ this._hide();
+
+ return this._update();
+ }
+
+ _showGrid() {
+ this.getElement("canvas").removeAttribute("hidden");
+ }
+
+ _showGridAreaInfoBar() {
+ this.getElement("area-infobar-container").removeAttribute("hidden");
+ }
+
+ _showGridCellInfoBar() {
+ this.getElement("cell-infobar-container").removeAttribute("hidden");
+ }
+
+ _showGridElements() {
+ this.getElement("elements").removeAttribute("hidden");
+ }
+
+ _showGridLineInfoBar() {
+ this.getElement("line-infobar-container").removeAttribute("hidden");
+ }
+
+ /**
+ * Shows all the grid area highlights for the current grid.
+ */
+ showAllGridAreas() {
+ this.renderGridArea();
+ }
+
+ /**
+ * Shows the grid area highlight for the given area name.
+ *
+ * @param {String} areaName
+ * Grid area name.
+ */
+ showGridArea(areaName) {
+ this.renderGridArea(areaName);
+ }
+
+ /**
+ * Shows the grid cell highlight for the given grid cell options.
+ *
+ * @param {Number} options.gridFragmentIndex
+ * Index of the grid fragment to render the grid cell highlight.
+ * @param {Number} options.rowNumber
+ * Row number of the grid cell to highlight.
+ * @param {Number} options.columnNumber
+ * Column number of the grid cell to highlight.
+ */
+ showGridCell({ gridFragmentIndex, rowNumber, columnNumber }) {
+ this.renderGridCell(gridFragmentIndex, rowNumber, columnNumber);
+ }
+
+ /**
+ * Shows the grid line highlight for the given grid line options.
+ *
+ * @param {Number} options.gridFragmentIndex
+ * Index of the grid fragment to render the grid line highlight.
+ * @param {Number} options.lineNumber
+ * Line number of the grid line to highlight.
+ * @param {String} options.type
+ * The dimension type of the grid line.
+ */
+ showGridLineNames({ gridFragmentIndex, lineNumber, type }) {
+ this.renderGridLineNames(gridFragmentIndex, lineNumber, type);
+ }
+
+ /**
+ * If a page hide event is triggered for current window's highlighter, hide the
+ * highlighter.
+ */
+ onPageHide({ target }) {
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Called when the page will-navigate. Used to hide the grid highlighter and clear
+ * the cached gap patterns and avoid using DeadWrapper obejcts as gap patterns the
+ * next time.
+ */
+ onWillNavigate({ isTopLevel }) {
+ this.clearCache();
+
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+
+ renderFragment(fragment) {
+ if (!this.isValidFragment(fragment)) {
+ return;
+ }
+
+ this.renderLines(
+ fragment.cols,
+ COLUMNS,
+ this.getFirstRowLinePos(fragment),
+ this.getLastRowLinePos(fragment)
+ );
+ this.renderLines(
+ fragment.rows,
+ ROWS,
+ this.getFirstColLinePos(fragment),
+ this.getLastColLinePos(fragment)
+ );
+
+ if (this.options.showGridAreasOverlay) {
+ this.renderGridAreaOverlay();
+ }
+
+ // Line numbers are rendered in a 2nd step to avoid overlapping with existing lines.
+ if (this.options.showGridLineNumbers) {
+ this.renderLineNumbers(
+ fragment.cols,
+ COLUMNS,
+ this.getFirstRowLinePos(fragment)
+ );
+ this.renderLineNumbers(
+ fragment.rows,
+ ROWS,
+ this.getFirstColLinePos(fragment)
+ );
+ this.renderNegativeLineNumbers(
+ fragment.cols,
+ COLUMNS,
+ this.getLastRowLinePos(fragment)
+ );
+ this.renderNegativeLineNumbers(
+ fragment.rows,
+ ROWS,
+ this.getLastColLinePos(fragment)
+ );
+ }
+ }
+
+ /**
+ * Render the grid area highlight for the given area name or for all the grid areas.
+ *
+ * @param {String} areaName
+ * Name of the grid area to be highlighted. If no area name is provided, all
+ * the grid areas should be highlighted.
+ */
+ renderGridArea(areaName) {
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const paths = [];
+
+ for (let i = 0; i < this.gridData.length; i++) {
+ const fragment = this.gridData[i];
+
+ for (const area of fragment.areas) {
+ if (areaName && areaName != area.name) {
+ continue;
+ }
+
+ const rowStart = fragment.rows.lines[area.rowStart - 1];
+ const rowEnd = fragment.rows.lines[area.rowEnd - 1];
+ const columnStart = fragment.cols.lines[area.columnStart - 1];
+ const columnEnd = fragment.cols.lines[area.columnEnd - 1];
+
+ const x1 = columnStart.start + columnStart.breadth;
+ const y1 = rowStart.start + rowStart.breadth;
+ const x2 = columnEnd.start;
+ const y2 = rowEnd.start;
+
+ const points = getPointsFromDiagonal(
+ x1,
+ y1,
+ x2,
+ y2,
+ this.currentMatrix
+ );
+
+ // Scale down by `devicePixelRatio` since SVG element already take them into
+ // account.
+ const svgPoints = points.map(point => ({
+ x: Math.round(point.x / devicePixelRatio),
+ y: Math.round(point.y / devicePixelRatio),
+ }));
+
+ // Scale down by `displayPixelRatio` since infobar's HTML elements already take it
+ // into account; and the zoom scaling is handled by `moveInfobar`.
+ const bounds = getBoundsFromPoints(
+ points.map(point => ({
+ x: Math.round(point.x / displayPixelRatio),
+ y: Math.round(point.y / displayPixelRatio),
+ }))
+ );
+
+ paths.push(getPathDescriptionFromPoints(svgPoints));
+
+ // Update and show the info bar when only displaying a single grid area.
+ if (areaName) {
+ this._showGridAreaInfoBar();
+ this._updateGridAreaInfobar(area, bounds);
+ }
+ }
+ }
+
+ const areas = this.getElement("areas");
+ areas.setAttribute("d", paths.join(" "));
+ }
+
+ /**
+ * Render grid area name on the containing grid area cell.
+ *
+ * @param {Object} fragment
+ * The grid fragment of the grid container.
+ * @param {Object} area
+ * The area overlay to render on the CSS highlighter canvas.
+ */
+ renderGridAreaName(fragment, area) {
+ const { rowStart, rowEnd, columnStart, columnEnd } = area;
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const offset = (displayPixelRatio / 2) % 1;
+ let fontSize = GRID_AREA_NAME_FONT_SIZE * displayPixelRatio;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ this.ctx.save();
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+ this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
+ this.ctx.globalAlpha = this.globalAlpha;
+ this.ctx.strokeStyle = this.color;
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+
+ // Draw the text for the grid area name.
+ for (let rowNumber = rowStart; rowNumber < rowEnd; rowNumber++) {
+ for (
+ let columnNumber = columnStart;
+ columnNumber < columnEnd;
+ columnNumber++
+ ) {
+ const row = fragment.rows.tracks[rowNumber - 1];
+ const column = fragment.cols.tracks[columnNumber - 1];
+
+ // If the font size exceeds the bounds of the containing grid cell, size it its
+ // row or column dimension, whichever is smallest.
+ if (
+ fontSize > column.breadth * displayPixelRatio ||
+ fontSize > row.breadth * displayPixelRatio
+ ) {
+ fontSize = Math.min([column.breadth, row.breadth]);
+ this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
+ }
+
+ const textWidth = this.ctx.measureText(area.name).width;
+ // The width of the character 'm' approximates the height of the text.
+ const textHeight = this.ctx.measureText("m").width;
+ // Padding in pixels for the line number text inside of the line number container.
+ const padding = 3 * displayPixelRatio;
+
+ const boxWidth = textWidth + 2 * padding;
+ const boxHeight = textHeight + 2 * padding;
+
+ let x = column.start + column.breadth / 2;
+ let y = row.start + row.breadth / 2;
+
+ [x, y] = apply(this.currentMatrix, [x, y]);
+
+ const rectXPos = x - boxWidth / 2;
+ const rectYPos = y - boxHeight / 2;
+
+ // Draw a rounded rectangle with a border width of 1 pixel,
+ // a border color matching the grid color, and a white background.
+ this.ctx.lineWidth = 1 * displayPixelRatio;
+ this.ctx.strokeStyle = this.color;
+ this.ctx.fillStyle = "white";
+ const radius = 2 * displayPixelRatio;
+ drawRoundedRect(
+ this.ctx,
+ rectXPos,
+ rectYPos,
+ boxWidth,
+ boxHeight,
+ radius
+ );
+
+ this.ctx.fillStyle = this.color;
+ this.ctx.fillText(area.name, x, y + padding);
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ /**
+ * Renders the grid area overlay on the css grid highlighter canvas.
+ */
+ renderGridAreaOverlay() {
+ const padding = 1;
+
+ for (let i = 0; i < this.gridData.length; i++) {
+ const fragment = this.gridData[i];
+
+ for (const area of fragment.areas) {
+ const { rowStart, rowEnd, columnStart, columnEnd, type } = area;
+
+ if (type === "implicit") {
+ continue;
+ }
+
+ // Draw the line edges for the grid area.
+ const areaColStart = fragment.cols.lines[columnStart - 1];
+ const areaColEnd = fragment.cols.lines[columnEnd - 1];
+
+ const areaRowStart = fragment.rows.lines[rowStart - 1];
+ const areaRowEnd = fragment.rows.lines[rowEnd - 1];
+
+ const areaColStartLinePos = areaColStart.start + areaColStart.breadth;
+ const areaRowStartLinePos = areaRowStart.start + areaRowStart.breadth;
+
+ this.renderLine(
+ areaColStartLinePos + padding,
+ areaRowStartLinePos,
+ areaRowEnd.start,
+ COLUMNS,
+ "areaEdge"
+ );
+ this.renderLine(
+ areaColEnd.start - padding,
+ areaRowStartLinePos,
+ areaRowEnd.start,
+ COLUMNS,
+ "areaEdge"
+ );
+
+ this.renderLine(
+ areaRowStartLinePos + padding,
+ areaColStartLinePos,
+ areaColEnd.start,
+ ROWS,
+ "areaEdge"
+ );
+ this.renderLine(
+ areaRowEnd.start - padding,
+ areaColStartLinePos,
+ areaColEnd.start,
+ ROWS,
+ "areaEdge"
+ );
+
+ this.renderGridAreaName(fragment, area);
+ }
+ }
+ }
+
+ /**
+ * Render the grid cell highlight for the given grid fragment index, row and column
+ * number.
+ *
+ * @param {Number} gridFragmentIndex
+ * Index of the grid fragment to render the grid cell highlight.
+ * @param {Number} rowNumber
+ * Row number of the grid cell to highlight.
+ * @param {Number} columnNumber
+ * Column number of the grid cell to highlight.
+ */
+ renderGridCell(gridFragmentIndex, rowNumber, columnNumber) {
+ const fragment = this.gridData[gridFragmentIndex];
+
+ if (!fragment) {
+ return;
+ }
+
+ const row = fragment.rows.tracks[rowNumber - 1];
+ const column = fragment.cols.tracks[columnNumber - 1];
+
+ if (!row || !column) {
+ return;
+ }
+
+ const x1 = column.start;
+ const y1 = row.start;
+ const x2 = column.start + column.breadth;
+ const y2 = row.start + row.breadth;
+
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const points = getPointsFromDiagonal(x1, y1, x2, y2, this.currentMatrix);
+
+ // Scale down by `devicePixelRatio` since SVG element already take them into account.
+ const svgPoints = points.map(point => ({
+ x: Math.round(point.x / devicePixelRatio),
+ y: Math.round(point.y / devicePixelRatio),
+ }));
+
+ // Scale down by `displayPixelRatio` since infobar's HTML elements already take it
+ // into account, and the zoom scaling is handled by `moveInfobar`.
+ const bounds = getBoundsFromPoints(
+ points.map(point => ({
+ x: Math.round(point.x / displayPixelRatio),
+ y: Math.round(point.y / displayPixelRatio),
+ }))
+ );
+
+ const cells = this.getElement("cells");
+ cells.setAttribute("d", getPathDescriptionFromPoints(svgPoints));
+
+ this._showGridCellInfoBar();
+ this._updateGridCellInfobar(rowNumber, columnNumber, bounds);
+ }
+
+ /**
+ * Render the grid gap area on the css grid highlighter canvas.
+ *
+ * @param {Number} linePos
+ * The line position along the x-axis for a column grid line and
+ * y-axis for a row grid line.
+ * @param {Number} startPos
+ * The start position of the cross side of the grid line.
+ * @param {Number} endPos
+ * The end position of the cross side of the grid line.
+ * @param {Number} breadth
+ * The grid line breadth value.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ */
+ renderGridGap(linePos, startPos, endPos, breadth, dimensionType) {
+ const { devicePixelRatio } = this.win;
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const offset = (displayPixelRatio / 2) % 1;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ linePos = Math.round(linePos);
+ startPos = Math.round(startPos);
+ breadth = Math.round(breadth);
+
+ this.ctx.save();
+ this.ctx.fillStyle = this.getGridGapPattern(
+ devicePixelRatio,
+ dimensionType
+ );
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+
+ if (dimensionType === COLUMNS) {
+ if (isFinite(endPos)) {
+ endPos = Math.round(endPos);
+ } else {
+ endPos = this._winDimensions.height;
+ startPos = -endPos;
+ }
+ drawRect(
+ this.ctx,
+ linePos,
+ startPos,
+ linePos + breadth,
+ endPos,
+ this.currentMatrix
+ );
+ } else {
+ if (isFinite(endPos)) {
+ endPos = Math.round(endPos);
+ } else {
+ endPos = this._winDimensions.width;
+ startPos = -endPos;
+ }
+ drawRect(
+ this.ctx,
+ startPos,
+ linePos,
+ endPos,
+ linePos + breadth,
+ this.currentMatrix
+ );
+ }
+
+ // Find current angle of grid by measuring the angle of two arbitrary points,
+ // then rotate canvas, so the hash pattern stays 45deg to the gridlines.
+ const p1 = apply(this.currentMatrix, [0, 0]);
+ const p2 = apply(this.currentMatrix, [1, 0]);
+ const angleRad = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]);
+ this.ctx.rotate(angleRad);
+
+ this.ctx.fill();
+ this.ctx.restore();
+ }
+
+ /**
+ * Render the grid line name highlight for the given grid fragment index, lineNumber,
+ * and dimensionType.
+ *
+ * @param {Number} gridFragmentIndex
+ * Index of the grid fragment to render the grid line highlight.
+ * @param {Number} lineNumber
+ * Line number of the grid line to highlight.
+ * @param {String} dimensionType
+ * The dimension type of the grid line.
+ */
+ renderGridLineNames(gridFragmentIndex, lineNumber, dimensionType) {
+ const fragment = this.gridData[gridFragmentIndex];
+
+ if (!fragment || !lineNumber || !dimensionType) {
+ return;
+ }
+
+ const { names } = fragment[dimensionType].lines[lineNumber - 1];
+ let linePos;
+
+ if (dimensionType === ROWS) {
+ linePos = fragment.rows.lines[lineNumber - 1];
+ } else if (dimensionType === COLUMNS) {
+ linePos = fragment.cols.lines[lineNumber - 1];
+ }
+
+ if (!linePos) {
+ return;
+ }
+
+ const currentZoom = getCurrentZoom(this.win);
+ const { bounds } = this.currentQuads.content[gridFragmentIndex];
+
+ const rowYPosition = fragment.rows.lines[0];
+ const colXPosition = fragment.rows.lines[0];
+
+ const x =
+ dimensionType === COLUMNS
+ ? linePos.start + bounds.left / currentZoom
+ : colXPosition.start + bounds.left / currentZoom;
+
+ const y =
+ dimensionType === ROWS
+ ? linePos.start + bounds.top / currentZoom
+ : rowYPosition.start + bounds.top / currentZoom;
+
+ this._showGridLineInfoBar();
+ this._updateGridLineInfobar(names.join(", "), lineNumber, x, y);
+ }
+
+ /**
+ * Render the grid line number on the css grid highlighter canvas.
+ *
+ * @param {Number} lineNumber
+ * The grid line number.
+ * @param {Number} linePos
+ * The line position along the x-axis for a column grid line and
+ * y-axis for a row grid line.
+ * @param {Number} startPos
+ * The start position of the cross side of the grid line.
+ * @param {Number} breadth
+ * The grid line breadth value.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Boolean||undefined} isStackedLine
+ * Boolean indicating if the line is stacked.
+ */
+ // eslint-disable-next-line complexity
+ renderGridLineNumber(
+ lineNumber,
+ linePos,
+ startPos,
+ breadth,
+ dimensionType,
+ isStackedLine
+ ) {
+ const displayPixelRatio = getDisplayPixelRatio(this.win);
+ const { devicePixelRatio } = this.win;
+ const offset = (displayPixelRatio / 2) % 1;
+ const fontSize = GRID_FONT_SIZE * devicePixelRatio;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ linePos = Math.round(linePos);
+ startPos = Math.round(startPos);
+ breadth = Math.round(breadth);
+
+ if (linePos + breadth < 0) {
+ // Don't render the line number since the line is not visible on screen.
+ return;
+ }
+
+ this.ctx.save();
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+ this.ctx.font = fontSize + "px " + GRID_FONT_FAMILY;
+
+ // For a general grid box, the height of the character "m" will be its minimum width
+ // and height. If line number's text width is greater, then use the grid box's text
+ // width instead.
+ const textHeight = this.ctx.measureText("m").width;
+ const textWidth = Math.max(
+ textHeight,
+ this.ctx.measureText(lineNumber).width
+ );
+
+ // Padding in pixels for the line number text inside of the line number container.
+ const padding = 3 * devicePixelRatio;
+ const offsetFromEdge = 2 * devicePixelRatio;
+
+ let boxWidth = textWidth + 2 * padding;
+ let boxHeight = textHeight + 2 * padding;
+
+ // Calculate the x & y coordinates for the line number container, so that its arrow
+ // tip is centered on the line (or the gap if there is one), and is offset by the
+ // calculated padding value from the grid container edge.
+ let x, y;
+
+ if (dimensionType === COLUMNS) {
+ x = linePos + breadth / 2;
+ y =
+ lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge;
+ } else if (dimensionType === ROWS) {
+ y = linePos + breadth / 2;
+ x =
+ lineNumber > 0 ? startPos - offsetFromEdge : startPos + offsetFromEdge;
+ }
+
+ [x, y] = apply(this.currentMatrix, [x, y]);
+
+ // Draw a bubble rectangular arrow with a border width of 2 pixels, a border color
+ // matching the grid color and a white background (the line number will be written in
+ // black).
+ this.ctx.lineWidth = 2 * displayPixelRatio;
+ this.ctx.strokeStyle = this.color;
+ this.ctx.fillStyle = "white";
+ this.ctx.globalAlpha = this.globalAlpha;
+
+ // See param definitions of drawBubbleRect.
+ const radius = 2 * displayPixelRatio;
+ const margin = 2 * displayPixelRatio;
+ const arrowSize = 8 * displayPixelRatio;
+
+ const minBoxSize = arrowSize * 2 + padding;
+ boxWidth = Math.max(boxWidth, minBoxSize);
+ boxHeight = Math.max(boxHeight, minBoxSize);
+
+ // Determine which edge of the box to aim the line number arrow at.
+ const boxEdge = this.getBoxEdge(dimensionType, lineNumber);
+
+ let { width, height } = this._winDimensions;
+ width *= displayPixelRatio;
+ height *= displayPixelRatio;
+
+ // Don't draw if the line is out of the viewport.
+ if (
+ (dimensionType === ROWS && (y < 0 || y > height)) ||
+ (dimensionType === COLUMNS && (x < 0 || x > width))
+ ) {
+ this.ctx.restore();
+ return;
+ }
+
+ // If the arrow's edge (the one perpendicular to the line direction) is too close to
+ // the edge of the viewport. Push the arrow inside the grid.
+ const minOffsetFromEdge = OFFSET_FROM_EDGE * displayPixelRatio;
+ switch (boxEdge) {
+ case "left":
+ if (x < minOffsetFromEdge) {
+ x += FLIP_ARROW_INSIDE_FACTOR * boxWidth;
+ }
+ break;
+ case "right":
+ if (width - x < minOffsetFromEdge) {
+ x -= FLIP_ARROW_INSIDE_FACTOR * boxWidth;
+ }
+ break;
+ case "top":
+ if (y < minOffsetFromEdge) {
+ y += FLIP_ARROW_INSIDE_FACTOR * boxHeight;
+ }
+ break;
+ case "bottom":
+ if (height - y < minOffsetFromEdge) {
+ y -= FLIP_ARROW_INSIDE_FACTOR * boxHeight;
+ }
+ break;
+ }
+
+ // Offset stacked line numbers by a quarter of the box's width/height, so a part of
+ // them remains visible behind the number that sits at the top of the stack.
+ if (isStackedLine) {
+ const xOffset = boxWidth / 4;
+ const yOffset = boxHeight / 4;
+
+ if (lineNumber > 0) {
+ x -= xOffset;
+ y -= yOffset;
+ } else {
+ x += xOffset;
+ y += yOffset;
+ }
+ }
+
+ // If one the edges of the arrow that's parallel to the line is too close to the edge
+ // of the viewport (and therefore partly hidden), grow the arrow's size in the
+ // opposite direction.
+ // The goal is for the part that's not hidden to be exactly the size of a normal
+ // arrow and for the arrow to keep pointing at the line (keep being centered on it).
+ let grewBox = false;
+ const boxWidthBeforeGrowth = boxWidth;
+ const boxHeightBeforeGrowth = boxHeight;
+
+ if (dimensionType === ROWS && y <= boxHeight / 2) {
+ grewBox = true;
+ boxHeight = 2 * (boxHeight - y);
+ } else if (dimensionType === ROWS && y >= height - boxHeight / 2) {
+ grewBox = true;
+ boxHeight = 2 * (y - height + boxHeight);
+ } else if (dimensionType === COLUMNS && x <= boxWidth / 2) {
+ grewBox = true;
+ boxWidth = 2 * (boxWidth - x);
+ } else if (dimensionType === COLUMNS && x >= width - boxWidth / 2) {
+ grewBox = true;
+ boxWidth = 2 * (x - width + boxWidth);
+ }
+
+ // Draw the arrow box itself
+ drawBubbleRect(
+ this.ctx,
+ x,
+ y,
+ boxWidth,
+ boxHeight,
+ radius,
+ margin,
+ arrowSize,
+ boxEdge
+ );
+
+ // Determine the text position for it to be centered nicely inside the arrow box.
+ switch (boxEdge) {
+ case "left":
+ x -= boxWidth + arrowSize + radius - boxWidth / 2;
+ break;
+ case "right":
+ x += boxWidth + arrowSize + radius - boxWidth / 2;
+ break;
+ case "top":
+ y -= boxHeight + arrowSize + radius - boxHeight / 2;
+ break;
+ case "bottom":
+ y += boxHeight + arrowSize + radius - boxHeight / 2;
+ break;
+ }
+
+ // Do a second pass to adjust the position, along the other axis, if the box grew
+ // during the previous step, so the text is also centered on that axis.
+ if (grewBox) {
+ if (dimensionType === ROWS && y <= boxHeightBeforeGrowth / 2) {
+ y = boxHeightBeforeGrowth / 2;
+ } else if (
+ dimensionType === ROWS &&
+ y >= height - boxHeightBeforeGrowth / 2
+ ) {
+ y = height - boxHeightBeforeGrowth / 2;
+ } else if (dimensionType === COLUMNS && x <= boxWidthBeforeGrowth / 2) {
+ x = boxWidthBeforeGrowth / 2;
+ } else if (
+ dimensionType === COLUMNS &&
+ x >= width - boxWidthBeforeGrowth / 2
+ ) {
+ x = width - boxWidthBeforeGrowth / 2;
+ }
+ }
+
+ // Write the line number inside of the rectangle.
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+ this.ctx.fillStyle = "black";
+ const numberText = isStackedLine ? "" : lineNumber;
+ this.ctx.fillText(numberText, x, y);
+ this.ctx.restore();
+ }
+
+ /**
+ * Determine which edge of a line number box to aim the line number arrow at.
+ *
+ * @param {String} dimensionType
+ * The grid line dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} lineNumber
+ * The grid line number.
+ * @return {String} The edge of the box: top, right, bottom or left.
+ */
+ getBoxEdge(dimensionType, lineNumber) {
+ let boxEdge;
+
+ if (dimensionType === COLUMNS) {
+ boxEdge = lineNumber > 0 ? "top" : "bottom";
+ } else if (dimensionType === ROWS) {
+ boxEdge = lineNumber > 0 ? "left" : "right";
+ }
+
+ // Rotate box edge as needed for writing mode and text direction.
+ const { direction, writingMode } = getComputedStyle(this.currentNode);
+
+ switch (writingMode) {
+ case "horizontal-tb":
+ // This is the initial value. No further adjustment needed.
+ break;
+ case "vertical-rl":
+ boxEdge = rotateEdgeRight(boxEdge);
+ break;
+ case "vertical-lr":
+ if (dimensionType === COLUMNS) {
+ boxEdge = rotateEdgeLeft(boxEdge);
+ } else {
+ boxEdge = rotateEdgeRight(boxEdge);
+ }
+ break;
+ case "sideways-rl":
+ boxEdge = rotateEdgeRight(boxEdge);
+ break;
+ case "sideways-lr":
+ boxEdge = rotateEdgeLeft(boxEdge);
+ break;
+ default:
+ console.error(`Unexpected writing-mode: ${writingMode}`);
+ }
+
+ switch (direction) {
+ case "ltr":
+ // This is the initial value. No further adjustment needed.
+ break;
+ case "rtl":
+ if (dimensionType === ROWS) {
+ boxEdge = reflectEdge(boxEdge);
+ }
+ break;
+ default:
+ console.error(`Unexpected direction: ${direction}`);
+ }
+
+ return boxEdge;
+ }
+
+ /**
+ * Render the grid line on the css grid highlighter canvas.
+ *
+ * @param {Number} linePos
+ * The line position along the x-axis for a column grid line and
+ * y-axis for a row grid line.
+ * @param {Number} startPos
+ * The start position of the cross side of the grid line.
+ * @param {Number} endPos
+ * The end position of the cross side of the grid line.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {String} lineType
+ * The grid line type - "edge", "explicit", or "implicit".
+ */
+ renderLine(linePos, startPos, endPos, dimensionType, lineType) {
+ const { devicePixelRatio } = this.win;
+ const lineWidth = getDisplayPixelRatio(this.win);
+ const offset = (lineWidth / 2) % 1;
+ const canvasX = Math.round(this._canvasPosition.x * devicePixelRatio);
+ const canvasY = Math.round(this._canvasPosition.y * devicePixelRatio);
+
+ linePos = Math.round(linePos);
+ startPos = Math.round(startPos);
+ endPos = Math.round(endPos);
+
+ this.ctx.save();
+ this.ctx.setLineDash(GRID_LINES_PROPERTIES[lineType].lineDash);
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+
+ const lineOptions = {
+ matrix: this.currentMatrix,
+ };
+
+ if (this.options.showInfiniteLines) {
+ lineOptions.extendToBoundaries = [
+ canvasX,
+ canvasY,
+ canvasX + CANVAS_SIZE,
+ canvasY + CANVAS_SIZE,
+ ];
+ }
+
+ if (dimensionType === COLUMNS) {
+ drawLine(this.ctx, linePos, startPos, linePos, endPos, lineOptions);
+ } else {
+ drawLine(this.ctx, startPos, linePos, endPos, linePos, lineOptions);
+ }
+
+ this.ctx.strokeStyle = this.color;
+ this.ctx.globalAlpha =
+ GRID_LINES_PROPERTIES[lineType].alpha * this.globalAlpha;
+
+ if (GRID_LINES_PROPERTIES[lineType].lineWidth) {
+ this.ctx.lineWidth =
+ GRID_LINES_PROPERTIES[lineType].lineWidth * devicePixelRatio;
+ } else {
+ this.ctx.lineWidth = lineWidth;
+ }
+
+ this.ctx.stroke();
+ this.ctx.restore();
+ }
+
+ /**
+ * Render the grid lines given the grid dimension information of the
+ * column or row lines.
+ *
+ * @param {GridDimension} gridDimension
+ * Column or row grid dimension object.
+ * @param {Object} quad.bounds
+ * The content bounds of the box model region quads.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} startPos
+ * The start position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ * @param {Number} endPos
+ * The end position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ */
+ renderLines(gridDimension, dimensionType, startPos, endPos) {
+ const { lines, tracks } = gridDimension;
+ const lastEdgeLineIndex = this.getLastEdgeLineIndex(tracks);
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const linePos = line.start;
+
+ if (i == 0 || i == lastEdgeLineIndex) {
+ this.renderLine(linePos, startPos, endPos, dimensionType, "edge");
+ } else {
+ this.renderLine(
+ linePos,
+ startPos,
+ endPos,
+ dimensionType,
+ tracks[i - 1].type
+ );
+ }
+
+ // Render a second line to illustrate the gutter for non-zero breadth.
+ if (line.breadth > 0) {
+ this.renderGridGap(
+ linePos,
+ startPos,
+ endPos,
+ line.breadth,
+ dimensionType
+ );
+ this.renderLine(
+ linePos + line.breadth,
+ startPos,
+ endPos,
+ dimensionType,
+ tracks[i].type
+ );
+ }
+ }
+ }
+
+ /**
+ * Render the grid lines given the grid dimension information of the
+ * column or row lines.
+ *
+ * @param {GridDimension} gridDimension
+ * Column or row grid dimension object.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} startPos
+ * The start position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ */
+ renderLineNumbers(gridDimension, dimensionType, startPos) {
+ const { lines, tracks } = gridDimension;
+
+ for (let i = 0, line; (line = lines[i++]); ) {
+ // If you place something using negative numbers, you can trigger some implicit
+ // grid creation above and to the left of the explicit grid (assuming a
+ // horizontal-tb writing mode).
+ //
+ // The first explicit grid line gets the number of 1, and any implicit grid lines
+ // before 1 get negative numbers. Since here we're rendering only the positive line
+ // numbers, we have to skip any implicit grid lines before the first one that is
+ // explicit. The API returns a 0 as the line's number for these implicit lines that
+ // occurs before the first explicit line.
+ if (line.number === 0) {
+ continue;
+ }
+
+ // Check for overlapping lines by measuring the track width between them.
+ // We render a second box beneath the last overlapping
+ // line number to indicate there are lines beneath it.
+ const gridTrack = tracks[i - 1];
+
+ if (gridTrack) {
+ const { breadth } = gridTrack;
+
+ if (breadth === 0) {
+ this.renderGridLineNumber(
+ line.number,
+ line.start,
+ startPos,
+ line.breadth,
+ dimensionType,
+ true
+ );
+ continue;
+ }
+ }
+
+ this.renderGridLineNumber(
+ line.number,
+ line.start,
+ startPos,
+ line.breadth,
+ dimensionType
+ );
+ }
+ }
+
+ /**
+ * Render the negative grid lines given the grid dimension information of the
+ * column or row lines.
+ *
+ * @param {GridDimension} gridDimension
+ * Column or row grid dimension object.
+ * @param {String} dimensionType
+ * The grid dimension type which is either the constant COLUMNS or ROWS.
+ * @param {Number} startPos
+ * The start position of the cross side ("left" for ROWS and "top" for COLUMNS)
+ * of the grid dimension.
+ */
+ renderNegativeLineNumbers(gridDimension, dimensionType, startPos) {
+ const { lines, tracks } = gridDimension;
+
+ for (let i = 0, line; (line = lines[i++]); ) {
+ const linePos = line.start;
+ const negativeLineNumber = line.negativeNumber;
+
+ // Don't render any negative line number greater than -1.
+ if (negativeLineNumber == 0) {
+ break;
+ }
+
+ // Check for overlapping lines by measuring the track width between them.
+ // We render a second box beneath the last overlapping
+ // line number to indicate there are lines beneath it.
+ const gridTrack = tracks[i - 1];
+ if (gridTrack) {
+ const { breadth } = gridTrack;
+
+ // Ensure "-1" is always visible, since it is always the largest number.
+ if (breadth === 0 && negativeLineNumber != -1) {
+ this.renderGridLineNumber(
+ negativeLineNumber,
+ linePos,
+ startPos,
+ line.breadth,
+ dimensionType,
+ true
+ );
+ continue;
+ }
+ }
+
+ this.renderGridLineNumber(
+ negativeLineNumber,
+ linePos,
+ startPos,
+ line.breadth,
+ dimensionType
+ );
+ }
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)). Should be called whenever node's geometry
+ * or grid changes.
+ */
+ _update() {
+ setIgnoreLayoutChanges(true);
+
+ // Set z-index.
+ this.markup.content.root.firstElementChild.style.setProperty(
+ "z-index",
+ this.options.zIndex
+ );
+
+ const root = this.getElement("root");
+ const cells = this.getElement("cells");
+ const areas = this.getElement("areas");
+
+ // Set the grid cells and areas fill to the current grid colour.
+ cells.setAttribute("style", `fill: ${this.color}`);
+ areas.setAttribute("style", `fill: ${this.color}`);
+
+ // Hide the root element and force the reflow in order to get the proper window's
+ // dimensions without increasing them.
+ root.setAttribute("style", "display: none");
+ this.win.document.documentElement.offsetWidth;
+ this._winDimensions = getWindowDimensions(this.win);
+ const { width, height } = this._winDimensions;
+
+ // Updates the <canvas> element's position and size.
+ // It also clear the <canvas>'s drawing context.
+ updateCanvasElement(
+ this.canvas,
+ this._canvasPosition,
+ this.win.devicePixelRatio
+ );
+
+ // Clear the grid area highlights.
+ this.clearGridAreas();
+ this.clearGridCell();
+
+ // Update the current matrix used in our canvas' rendering.
+ const { currentMatrix, hasNodeTransformations } = getCurrentMatrix(
+ this.currentNode,
+ this.win
+ );
+ this.currentMatrix = currentMatrix;
+ this.hasNodeTransformations = hasNodeTransformations;
+
+ // Start drawing the grid fragments.
+ for (let i = 0; i < this.gridData.length; i++) {
+ this.renderFragment(this.gridData[i]);
+ }
+
+ // Display the grid area highlights if needed.
+ if (this.options.showAllGridAreas) {
+ this.showAllGridAreas();
+ } else if (this.options.showGridArea) {
+ this.showGridArea(this.options.showGridArea);
+ }
+
+ // Display the grid cell highlights if needed.
+ if (this.options.showGridCell) {
+ this.showGridCell(this.options.showGridCell);
+ }
+
+ // Display the grid line names if needed.
+ if (this.options.showGridLineNames) {
+ this.showGridLineNames(this.options.showGridLineNames);
+ }
+
+ this._showGrid();
+ this._showGridElements();
+
+ root.setAttribute(
+ "style",
+ `position: absolute; width: ${width}px; height: ${height}px; overflow: hidden`
+ );
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ return true;
+ }
+
+ /**
+ * Update the grid information displayed in the grid area info bar.
+ *
+ * @param {GridArea} area
+ * The grid area object.
+ * @param {Object} bounds
+ * A DOMRect-like object represent the grid area rectangle.
+ */
+ _updateGridAreaInfobar(area, bounds) {
+ const { width, height } = bounds;
+ const dim =
+ parseFloat(width.toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat(height.toPrecision(6));
+
+ this.getElement("area-infobar-name").setTextContent(area.name);
+ this.getElement("area-infobar-dimensions").setTextContent(dim);
+
+ const container = this.getElement("area-infobar-container");
+ moveInfobar(container, bounds, this.win, {
+ position: "bottom",
+ });
+ }
+
+ /**
+ * Update the grid information displayed in the grid cell info bar.
+ *
+ * @param {Number} rowNumber
+ * The grid cell's row number.
+ * @param {Number} columnNumber
+ * The grid cell's column number.
+ * @param {Object} bounds
+ * A DOMRect-like object represent the grid cell rectangle.
+ */
+ _updateGridCellInfobar(rowNumber, columnNumber, bounds) {
+ const { width, height } = bounds;
+ const dim =
+ parseFloat(width.toPrecision(6)) +
+ " \u00D7 " +
+ parseFloat(height.toPrecision(6));
+ const position = HighlightersBundle.formatValueSync(
+ "grid-row-column-positions",
+ { row: rowNumber, column: columnNumber }
+ );
+
+ this.getElement("cell-infobar-position").setTextContent(position);
+ this.getElement("cell-infobar-dimensions").setTextContent(dim);
+
+ const container = this.getElement("cell-infobar-container");
+ moveInfobar(container, bounds, this.win, {
+ position: "top",
+ });
+ }
+
+ /**
+ * Update the grid information displayed in the grid line info bar.
+ *
+ * @param {String} gridLineNames
+ * Comma-separated string of names for the grid line.
+ * @param {Number} gridLineNumber
+ * The grid line number.
+ * @param {Number} x
+ * The x-coordinate of the grid line.
+ * @param {Number} y
+ * The y-coordinate of the grid line.
+ */
+ _updateGridLineInfobar(gridLineNames, gridLineNumber, x, y) {
+ this.getElement("line-infobar-number").setTextContent(gridLineNumber);
+ this.getElement("line-infobar-names").setTextContent(gridLineNames);
+
+ const container = this.getElement("line-infobar-container");
+ moveInfobar(
+ container,
+ getBoundsFromPoints([
+ { x, y },
+ { x, y },
+ { x, y },
+ { x, y },
+ ]),
+ this.win
+ );
+ }
+}
+
+exports.CssGridHighlighter = CssGridHighlighter;
diff --git a/devtools/server/actors/highlighters/css-transform.js b/devtools/server/actors/highlighters/css-transform.js
new file mode 100644
index 0000000000..c9f16b42e0
--- /dev/null
+++ b/devtools/server/actors/highlighters/css-transform.js
@@ -0,0 +1,265 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ setIgnoreLayoutChanges,
+ getNodeBounds,
+} = require("resource://devtools/shared/layout/utils.js");
+
+// The minimum distance a line should be before it has an arrow marker-end
+const ARROW_LINE_MIN_DISTANCE = 10;
+
+var MARKER_COUNTER = 1;
+
+/**
+ * The CssTransformHighlighter is the class that draws an outline around a
+ * transformed element and an outline around where it would be if untransformed
+ * as well as arrows connecting the 2 outlines' corners.
+ */
+class CssTransformHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "css-transform-";
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ // The root wrapper is used to unzoom the highlighter when needed.
+ const rootWrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ id: "elements",
+ hidden: "true",
+ width: "100%",
+ height: "100%",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Add a marker tag to the svg root for the arrow tip
+ this.markerId = "arrow-marker-" + MARKER_COUNTER;
+ MARKER_COUNTER++;
+ const marker = this.markup.createSVGNode({
+ nodeType: "marker",
+ parent: svg,
+ attributes: {
+ id: this.markerId,
+ markerWidth: "10",
+ markerHeight: "5",
+ orient: "auto",
+ markerUnits: "strokeWidth",
+ refX: "10",
+ refY: "5",
+ viewBox: "0 0 10 10",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: marker,
+ attributes: {
+ d: "M 0 0 L 10 5 L 0 10 z",
+ fill: "#08C",
+ },
+ });
+
+ const shapesGroup = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ });
+
+ // Create the 2 polygons (transformed and untransformed)
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: shapesGroup,
+ attributes: {
+ id: "untransformed",
+ class: "untransformed",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: shapesGroup,
+ attributes: {
+ id: "transformed",
+ class: "transformed",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Create the arrows
+ for (const nb of ["1", "2", "3", "4"]) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: shapesGroup,
+ attributes: {
+ id: "line" + nb,
+ class: "line",
+ "marker-end": "url(#" + this.markerId + ")",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ return container;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ this.markup.destroy();
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ if (!this._isTransformed(this.currentNode)) {
+ this.hide();
+ return false;
+ }
+
+ return this._update();
+ }
+
+ /**
+ * Checks if the supplied node is transformed and not inline
+ */
+ _isTransformed(node) {
+ const style = getComputedStyle(node);
+ return style && style.transform !== "none" && style.display !== "inline";
+ }
+
+ _setPolygonPoints(quad, id) {
+ const points = [];
+ for (const point of ["p1", "p2", "p3", "p4"]) {
+ points.push(quad[point].x + "," + quad[point].y);
+ }
+ this.getElement(id).setAttribute("points", points.join(" "));
+ }
+
+ _setLinePoints(p1, p2, id) {
+ const line = this.getElement(id);
+ line.setAttribute("x1", p1.x);
+ line.setAttribute("y1", p1.y);
+ line.setAttribute("x2", p2.x);
+ line.setAttribute("y2", p2.y);
+
+ const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
+ if (dist < ARROW_LINE_MIN_DISTANCE) {
+ line.removeAttribute("marker-end");
+ } else {
+ line.setAttribute("marker-end", "url(#" + this.markerId + ")");
+ }
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)).
+ * Should be called whenever node size or attributes change
+ */
+ _update() {
+ setIgnoreLayoutChanges(true);
+
+ // Getting the points for the transformed shape
+ const quads = this.currentQuads.border;
+ if (
+ !quads.length ||
+ quads[0].bounds.width <= 0 ||
+ quads[0].bounds.height <= 0
+ ) {
+ this._hideShapes();
+ return false;
+ }
+
+ const [quad] = quads;
+
+ // Getting the points for the untransformed shape
+ const untransformedQuad = getNodeBounds(this.win, this.currentNode);
+
+ this._setPolygonPoints(quad, "transformed");
+ this._setPolygonPoints(untransformedQuad, "untransformed");
+ for (const nb of ["1", "2", "3", "4"]) {
+ this._setLinePoints(
+ untransformedQuad["p" + nb],
+ quad["p" + nb],
+ "line" + nb
+ );
+ }
+
+ // Adapt to the current zoom
+ this.markup.scaleRootElement(
+ this.currentNode,
+ this.ID_CLASS_PREFIX + "root"
+ );
+
+ this._showShapes();
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ return true;
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+ this._hideShapes();
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ _hideShapes() {
+ this.getElement("elements").setAttribute("hidden", "true");
+ }
+
+ _showShapes() {
+ this.getElement("elements").removeAttribute("hidden");
+ }
+}
+
+exports.CssTransformHighlighter = CssTransformHighlighter;
diff --git a/devtools/server/actors/highlighters/css/highlighters.css b/devtools/server/actors/highlighters/css/highlighters.css
new file mode 100644
index 0000000000..33c8a04aae
--- /dev/null
+++ b/devtools/server/actors/highlighters/css/highlighters.css
@@ -0,0 +1,1059 @@
+/* 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/. */
+
+:host { display: contents; }
+
+.highlighter-container {
+ --highlighter-accessibility-bounds-color: #6a5acd;
+ --highlighter-accessibility-bounds-opacity: 0.6;
+ --highlighter-box-border-color: #444444;
+ --highlighter-box-content-color: hsl(197, 71%, 73%);
+ --highlighter-box-margin-color: #edff64;
+ --highlighter-box-padding-color: #6a5acd;
+ --highlighter-bubble-text-color: hsl(216, 33%, 97%);
+ --highlighter-bubble-background-color: hsl(214, 13%, 24%);
+ --highlighter-bubble-border-color: rgba(255, 255, 255, 0.2);
+ --highlighter-bubble-arrow-size: 8px;
+ --highlighter-font-family: message-box;
+ --highlighter-font-size: 11px;
+ --highlighter-guide-color: hsl(200, 100%, 40%);
+ --highlighter-infobar-color: hsl(210, 30%, 85%);
+
+ --grey-40: #b1b1b3;
+ --red-40: #ff3b6b;
+ --yellow-60: #d7b600;
+ --blue-60: #0060df;
+}
+
+/**
+ * Highlighters are absolute positioned in the page by default.
+ * A single highlighter can have fixed position in its css class if needed (see below the
+ * eye dropper or rulers highlighter, for example); but if it has to handle the
+ * document's scrolling (as rulers does), it would lag a bit behind due the APZ (Async
+ * Pan/Zoom module), that performs asynchronously panning and zooming on the compositor
+ * thread rather than the main thread.
+ */
+.highlighter-container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ /* The container for all highlighters doesn't react to pointer-events by
+ default. This is because most highlighters cover the whole viewport but
+ don't contain UIs that need to be accessed.
+ If your highlighter has UI that needs to be interacted with, add
+ 'pointer-events:auto;' on its container element. */
+ pointer-events: none;
+}
+
+.highlighter-container.box-model {
+ /* Make the box-model container have a z-index other than auto so it always sits above
+ other highlighters. */
+ z-index: 1;
+}
+
+.highlighter-container [hidden] {
+ display: none !important;
+}
+
+.highlighter-container [dragging] {
+ cursor: grabbing;
+}
+
+/* Box Model Highlighter */
+
+.box-model-regions {
+ opacity: 0.6;
+}
+
+/* Box model regions can be faded (see the onlyRegionArea option in
+ highlighters.js) in order to only display certain regions. */
+.box-model-regions [faded] {
+ display: none;
+}
+
+.box-model-content {
+ fill: var(--highlighter-box-content-color);
+}
+
+.box-model-padding {
+ fill: var(--highlighter-box-padding-color);
+}
+
+.box-model-border {
+ fill: var(--highlighter-box-border-color);
+}
+
+.box-model-margin {
+ fill: var(--highlighter-box-margin-color);
+}
+
+.box-model-content,
+.box-model-padding,
+.box-model-border,
+.box-model-margin {
+ stroke: none;
+}
+
+.box-model-guide-top,
+.box-model-guide-right,
+.box-model-guide-bottom,
+.box-model-guide-left {
+ stroke: var(--highlighter-guide-color);
+ stroke-dasharray: 5 3;
+ shape-rendering: crispEdges;
+}
+
+@media (prefers-reduced-motion) {
+ .use-simple-highlighters :is(
+ .box-model-content,
+ .box-model-padding,
+ .box-model-border,
+ .box-model-margin
+ ) {
+ fill: none;
+ stroke-width: 3;
+ }
+
+ .use-simple-highlighters .box-model-content {
+ stroke: var(--highlighter-box-content-color);
+ }
+
+ .use-simple-highlighters .box-model-padding {
+ stroke: var(--highlighter-box-padding-color);
+ }
+
+ .use-simple-highlighters .box-model-border {
+ stroke: var(--highlighter-box-border-color);
+ }
+
+ .use-simple-highlighters .box-model-margin {
+ stroke: var(--highlighter-box-margin-color);
+ }
+}
+
+/* Highlighter - Infobar */
+
+[class$="infobar-container"] {
+ position: absolute;
+ max-width: 95%;
+
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+}
+
+[class$="infobar"] {
+ position: relative;
+
+ padding: 5px;
+ min-width: 75px;
+
+ border-radius: 3px;
+ background: var(--highlighter-bubble-background-color) no-repeat padding-box;
+
+ color: var(--highlighter-bubble-text-color);
+ text-shadow: none;
+
+ border: 1px solid var(--highlighter-bubble-border-color);
+}
+
+/* Arrows */
+
+[class$="infobar-container"] > [class$="infobar"]:before {
+ left: calc(50% - var(--highlighter-bubble-arrow-size));
+ border: var(--highlighter-bubble-arrow-size) solid
+ var(--highlighter-bubble-border-color);
+}
+
+[class$="infobar-container"] > [class$="infobar"]:after {
+ left: calc(50% - 7px);
+ border: 7px solid var(--highlighter-bubble-background-color);
+}
+
+[class$="infobar-container"] > [class$="infobar"]:before,
+[class$="infobar-container"] > [class$="infobar"]:after {
+ content: "";
+ display: none;
+ position: absolute;
+ height: 0;
+ width: 0;
+ border-left-color: transparent;
+ border-right-color: transparent;
+}
+
+[class$="infobar-container"][position="top"]:not([hide-arrow])
+ > [class$="infobar"]:before,
+[class$="infobar-container"][position="top"]:not([hide-arrow])
+ > [class$="infobar"]:after {
+ border-bottom: 0;
+ top: 100%;
+ display: block;
+}
+
+[class$="infobar-container"][position="bottom"]:not([hide-arrow])
+ > [class$="infobar"]:before,
+[class$="infobar-container"][position="bottom"]:not([hide-arrow])
+ > [class$="infobar"]:after {
+ border-top: 0;
+ bottom: 100%;
+ display: block;
+}
+
+/* Text Container */
+
+[class$="infobar-text"] {
+ overflow: hidden;
+ white-space: nowrap;
+ direction: ltr;
+ padding-bottom: 1px;
+ display: flex;
+ justify-content: center;
+ max-width: 768px;
+}
+
+.box-model-infobar-tagname {
+ color: hsl(285, 100%, 75%);
+}
+
+.box-model-infobar-id {
+ color: hsl(103, 46%, 54%);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.box-model-infobar-classes,
+.box-model-infobar-pseudo-classes {
+ color: hsl(200, 74%, 57%);
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+[class$="infobar-dimensions"],
+[class$="infobar-grid-type"],
+[class$="infobar-flex-type"] {
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+}
+
+[class$="infobar-grid-type"]:empty,
+[class$="infobar-flex-type"]:empty {
+ display: none;
+}
+
+[class$="infobar-dimensions"] {
+ color: var(--highlighter-infobar-color);
+}
+
+[class$="infobar-grid-type"],
+[class$="infobar-flex-type"] {
+ color: var(--grey-40);
+}
+
+/* CSS Grid Highlighter */
+
+.css-grid-canvas {
+ position: absolute;
+ pointer-events: none;
+ top: 0;
+ left: 0;
+ image-rendering: -moz-crisp-edges;
+}
+
+.css-grid-regions {
+ opacity: 0.6;
+}
+
+.css-grid-areas,
+.css-grid-cells {
+ opacity: 0.5;
+ stroke: none;
+}
+
+.css-grid-area-infobar-name,
+.css-grid-cell-infobar-position,
+.css-grid-line-infobar-number {
+ color: hsl(285, 100%, 75%);
+}
+
+.css-grid-line-infobar-names:not(:empty) {
+ color: var(--highlighter-infobar-color);
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+}
+
+/* CSS Transform Highlighter */
+
+.css-transform-transformed {
+ fill: var(--highlighter-box-content-color);
+ opacity: 0.8;
+}
+
+.css-transform-untransformed {
+ fill: #66cc52;
+ opacity: 0.8;
+}
+
+.css-transform-transformed,
+.css-transform-untransformed,
+.css-transform-line {
+ stroke: var(--highlighter-guide-color);
+ stroke-dasharray: 5 3;
+ stroke-width: 2;
+}
+
+/* Element Geometry Highlighter */
+
+.geometry-editor-root {
+ /* The geometry editor can be interacted with, so it needs to react to
+ pointer events */
+ pointer-events: auto;
+ user-select: none;
+}
+
+.geometry-editor-offset-parent {
+ stroke: var(--highlighter-guide-color);
+ shape-rendering: crispEdges;
+ stroke-dasharray: 5 3;
+ fill: transparent;
+}
+
+.geometry-editor-current-node {
+ stroke: var(--highlighter-guide-color);
+ fill: var(--highlighter-box-content-color);
+ shape-rendering: crispEdges;
+ opacity: 0.6;
+}
+
+.geometry-editor-arrow {
+ stroke: var(--highlighter-guide-color);
+ shape-rendering: crispEdges;
+}
+
+.geometry-editor-root circle {
+ stroke: var(--highlighter-guide-color);
+ fill: var(--highlighter-box-content-color);
+}
+
+.geometry-editor-handler-top,
+.geometry-editor-handler-bottom {
+ cursor: ns-resize;
+}
+
+.geometry-editor-handler-right,
+.geometry-editor-handler-left {
+ cursor: ew-resize;
+}
+
+[dragging] .geometry-editor-handler-top,
+[dragging] .geometry-editor-handler-right,
+[dragging] .geometry-editor-handler-bottom,
+[dragging] .geometry-editor-handler-left {
+ cursor: grabbing;
+}
+
+.geometry-editor-handler-top.dragging,
+.geometry-editor-handler-right.dragging,
+.geometry-editor-handler-bottom.dragging,
+.geometry-editor-handler-left.dragging {
+ fill: var(--highlighter-guide-color);
+}
+
+.geometry-editor-label-bubble {
+ fill: var(--highlighter-bubble-background-color);
+ shape-rendering: crispEdges;
+}
+
+.geometry-editor-label-text {
+ fill: var(--highlighter-bubble-text-color);
+ font: var(--highlighter-font-family);
+ font-size: 10px;
+ text-anchor: middle;
+ dominant-baseline: middle;
+}
+
+/* Rulers Highlighter */
+
+.rulers-highlighter-elements {
+ shape-rendering: crispEdges;
+ pointer-events: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+}
+
+.rulers-highlighter-elements > g {
+ opacity: 0.8;
+}
+
+.rulers-highlighter-elements > g > rect {
+ fill: #fff;
+}
+
+.rulers-highlighter-ruler-graduations {
+ stroke: #bebebe;
+}
+
+.rulers-highlighter-ruler-markers {
+ stroke: #202020;
+}
+
+.rulers-highlighter-horizontal-labels > text,
+.rulers-highlighter-vertical-labels > text {
+ stroke: none;
+ fill: #202020;
+ font: var(--highlighter-font-family);
+ font-size: 9px;
+ dominant-baseline: hanging;
+}
+
+.rulers-highlighter-horizontal-labels > text {
+ text-anchor: start;
+}
+
+.rulers-highlighter-vertical-labels > text {
+ transform: rotate(-90deg);
+ text-anchor: end;
+}
+
+.viewport-size-highlighter-viewport-infobar-container {
+ shape-rendering: crispEdges;
+ background-color: rgba(255, 255, 255, 0.7);
+ font: var(--highlighter-font-family);
+ position: fixed;
+ top: 30px;
+ right: 0px;
+ font-size: 12px;
+ padding: 4px;
+}
+
+/* Measuring Tool Highlighter */
+
+.measuring-tool-tool {
+ pointer-events: auto;
+}
+
+.measuring-tool-root {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: auto;
+ cursor: crosshair;
+}
+
+.measuring-tool-elements {
+ position: absolute;
+}
+
+.measuring-tool-root path {
+ shape-rendering: geometricPrecision;
+ pointer-events: auto;
+}
+
+.measuring-tool-root .measuring-tool-box-path,
+.measuring-tool-root .measuring-tool-diagonal-path {
+ fill: rgba(135, 206, 235, 0.6);
+ stroke: var(--highlighter-guide-color);
+}
+
+.measuring-tool-root circle {
+ stroke: var(--highlighter-guide-color);
+ stroke-width: 2px;
+ fill: #fff;
+ vector-effect: non-scaling-stroke;
+}
+
+.measuring-tool-root circle.highlight {
+ fill: var(--highlighter-guide-color);
+}
+
+.measuring-tool-handler-top,
+.measuring-tool-handler-bottom {
+ cursor: ns-resize;
+}
+
+.measuring-tool-handler-right,
+.measuring-tool-handler-left {
+ cursor: ew-resize;
+}
+
+.measuring-tool-handler-topleft,
+.measuring-tool-handler-bottomright {
+ cursor: nwse-resize;
+}
+
+.measuring-tool-handler-topright,
+.measuring-tool-handler-bottomleft {
+ cursor: nesw-resize;
+}
+
+.mirrored .measuring-tool-handler-topleft,
+.mirrored .measuring-tool-handler-bottomright {
+ cursor: nesw-resize;
+}
+
+.mirrored .measuring-tool-handler-topright,
+.mirrored .measuring-tool-handler-bottomleft {
+ cursor: nwse-resize;
+}
+
+[class^=measuring-tool-handler].dragging {
+ fill: var(--highlighter-guide-color);
+}
+
+.dragging .measuring-tool-box-path,
+.dragging .measuring-tool-diagonal-path {
+ opacity: 0.45;
+}
+
+.measuring-tool-label-size,
+.measuring-tool-label-position {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: inline-block;
+ border-radius: 4px;
+ padding: 4px;
+ white-space: pre-line;
+ font: var(--highlighter-font-family);
+ font-size: 10px;
+ pointer-events: none;
+ user-select: none;
+ box-sizing: border-box;
+}
+
+.measuring-tool-label-position {
+ color: #fff;
+ background: hsla(214, 13%, 24%, 0.8);
+}
+
+.measuring-tool-label-size {
+ color: var(--highlighter-bubble-text-color);
+ background: var(--highlighter-bubble-background-color);
+ border: 1px solid var(--highlighter-bubble-border-color);
+ line-height: 1.5em;
+}
+
+[class^=measuring-tool-guide] {
+ stroke: var(--highlighter-guide-color);
+ stroke-dasharray: 5 3;
+ shape-rendering: crispEdges;
+}
+
+/* Eye Dropper */
+
+.eye-dropper-root {
+ --magnifier-width: 96px;
+ --magnifier-height: 96px;
+ /* Width accounts for all color formats (hsl being the longest) */
+ --label-width: 160px;
+ --label-height: 23px;
+ --background-color: #e0e0e0;
+ color: #333;
+
+ position: fixed;
+ /* Tool start position. This should match the X/Y defines in JS */
+ top: 100px;
+ left: 100px;
+
+ /* Prevent interacting with the page when hovering and clicking */
+ pointer-events: auto;
+
+ /* Offset the UI so it is centered around the pointer */
+ transform: translate(
+ calc(var(--magnifier-width) / -2),
+ calc(var(--magnifier-height) / -2)
+ );
+
+ filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.4));
+
+ /* We don't need the UI to be reversed in RTL locales, otherwise the # would appear
+ to the right of the hex code. Force LTR */
+ direction: ltr;
+}
+
+.eye-dropper-canvas {
+ image-rendering: -moz-crisp-edges;
+ cursor: none;
+ width: var(--magnifier-width);
+ height: var(--magnifier-height);
+ border-radius: 50%;
+ box-shadow: 0 0 0 3px var(--background-color);
+ display: block;
+}
+
+.eye-dropper-color-container {
+ background-color: var(--background-color);
+ border-radius: 2px;
+ width: var(--label-width);
+ height: var(--label-height);
+ position: relative;
+
+ --label-horizontal-center: translateX(
+ calc((var(--magnifier-width) - var(--label-width)) / 2)
+ );
+ --label-horizontal-left: translateX(
+ calc((-1 * var(--label-width) + var(--magnifier-width) / 2))
+ );
+ --label-horizontal-right: translateX(calc(var(--magnifier-width) / 2));
+ --label-vertical-top: translateY(
+ calc((-1 * var(--magnifier-height)) - var(--label-height))
+ );
+
+ /* By default the color label container sits below the canvas.
+ Here we just center it horizontally */
+ transform: var(--label-horizontal-center);
+ transition: transform 0.1s ease-in-out;
+}
+
+/* If there isn't enough space below the canvas, we move the label container to the top */
+.eye-dropper-root[top] .eye-dropper-color-container {
+ transform: var(--label-horizontal-center) var(--label-vertical-top);
+}
+
+/* If there isn't enough space right of the canvas to horizontally center the label
+ container, offset it to the left */
+.eye-dropper-root[left] .eye-dropper-color-container {
+ transform: var(--label-horizontal-left);
+}
+
+.eye-dropper-root[left][top] .eye-dropper-color-container {
+ transform: var(--label-horizontal-left) var(--label-vertical-top);
+}
+
+/* If there isn't enough space left of the canvas to horizontally center the label
+ container, offset it to the right */
+.eye-dropper-root[right] .eye-dropper-color-container {
+ transform: var(--label-horizontal-right);
+}
+
+.eye-dropper-root[right][top] .eye-dropper-color-container {
+ transform: var(--label-horizontal-right) var(--label-vertical-top);
+}
+
+.eye-dropper-color-preview {
+ width: 16px;
+ height: 16px;
+ position: absolute;
+ inset-inline-start: 3px;
+ inset-block-start: 3px;
+ box-shadow: 0px 0px 0px black;
+ border: solid 1px #fff;
+}
+
+.eye-dropper-color-value {
+ text-shadow: 1px 1px 1px #fff;
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+ text-align: center;
+ padding: 4px 0;
+}
+
+/* Paused Debugger Overlay */
+
+.paused-dbg-root {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+
+ width: 100vw;
+ height: 100vh;
+
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+
+ /* We don't have access to DevTools themes here, but some of these colors come from the
+ themes. Theme variable names are given in comments. */
+ --text-color: #585959; /* --theme-body-color-alt */
+ --toolbar-background: #fcfcfc; /* --theme-toolbar-background */
+ --toolbar-border: #dde1e4; /* --theme-splitter-color */
+ --toolbar-box-shadow: 0 2px 2px 0 rgba(155, 155, 155, 0.26); /* --rdm-box-shadow */
+ --overlay-background: #dde1e4a8;
+}
+
+.paused-dbg-root[overlay] {
+ background-color: var(--overlay-background);
+ pointer-events: auto;
+}
+
+.paused-dbg-toolbar {
+ /* Show the toolbar at the top, but not too high to prevent it overlaping OS toolbar on Android */
+ margin-top: 30px;
+ display: inline-flex;
+ user-select: none;
+
+ color: var(--text-color);
+ box-shadow: var(--toolbar-box-shadow);
+ background-color: var(--toolbar-background);
+ border: 1px solid var(--toolbar-border);
+ border-radius: 4px;
+
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+}
+
+.paused-dbg-toolbar button {
+ margin: 8px 4px 6px 6px;
+ width: 16px;
+ height: 16px;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ mask-size: 16px 16px;
+ background-color: var(--text-color);
+
+ border: 0px;
+ appearance: none;
+}
+
+.paused-dbg-divider {
+ width: 1px;
+ height: 16px;
+ margin-top: 10px;
+ background-color: var(--toolbar-border);
+}
+
+.paused-dbg-reason,
+.paused-dbg-step-button-wrapper,
+.paused-dbg-resume-button-wrapper {
+ margin-top: 2px;
+ margin-bottom: 2px;
+}
+
+.paused-dbg-step-button-wrapper,
+.paused-dbg-resume-button-wrapper {
+ margin-left: 2px;
+ margin-right: 2px;
+}
+
+button.paused-dbg-step-button {
+ margin-left: 6px;
+ margin-right: 6px;
+ mask-image: url(resource://devtools-shared-images/stepOver.svg);
+ padding: 0;
+}
+
+button.paused-dbg-resume-button {
+ margin-right: 6px;
+ mask-image: url(resource://devtools-shared-images/resume.svg);
+ padding: 0;
+}
+
+.paused-dbg-step-button-wrapper.hover,
+.paused-dbg-resume-button-wrapper.hover {
+ background-color: var(--toolbar-border);
+ border-radius: 2px;
+}
+
+.paused-dbg-reason {
+ padding: 3px 16px;
+ margin: 8px 0px;
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+}
+
+
+/* Remote Node Picker Notice Highlighter */
+
+#node-picker-notice-root {
+ position: fixed;
+ max-width: 100vw;
+ /* Position at the bottom of the screen so it doesn't get into the user's way */
+ bottom: 0;
+ left: 0;
+ right: 0;
+
+ z-index: 2;
+
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+
+ /* We don't have access to DevTools themes here, but some of these colors come from the
+ themes. Theme variable names are given in comments. */
+ --text-color: #585959; /* --theme-body-color-alt */
+ --toolbar-background: #fcfcfc; /* --theme-toolbar-background */
+ --toolbar-border: #dde1e4; /* --theme-splitter-color */
+ --toolbar-button-hover-background: rgba(12, 12, 13, 0.15); /* --theme-toolbarbutton-hover-background */
+ --toolbar-box-shadow: 0 2px 2px 0 rgba(155, 155, 155, 0.26); /* --rdm-box-shadow */
+}
+
+#node-picker-notice-root[overlay] {
+ pointer-events: auto;
+}
+
+#node-picker-notice-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ padding: 8px 16px;
+
+ color: var(--text-color);
+ box-shadow: var(--toolbar-box-shadow);
+ background-color: var(--toolbar-background);
+ border: 1px solid var(--toolbar-border);
+ border-radius: 2px;
+
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+
+ user-select: none;
+}
+
+#node-picker-notice-info {
+ font: var(--highlighter-font-family);
+ font-size: var(--highlighter-font-size);
+ text-align: center;
+}
+
+#node-picker-notice-icon {
+ width: 16px;
+ height: 16px;
+
+ background-image: url(resource://devtools-shared-images/command-pick.svg);
+ -moz-context-properties: fill;
+ fill: currentColor;
+
+ background-size: contain;
+ background-repeat: no-repeat;
+}
+
+#node-picker-notice-icon.touch {
+ background-image: url(resource://devtools-shared-images/command-pick-remote-touch.svg);
+}
+
+
+#node-picker-notice-hide-button {
+ border: 0px;
+ border-radius: 2px;
+ appearance: none;
+ background-color: var(--toolbar-border);
+ color: currentColor;
+ font-size: 1em;
+ padding-inline: 4px;
+}
+
+/* We can't use :hover as it wouldn't work if the page is paused, so we add a specific class for this */
+#node-picker-notice-hide-button.hover {
+ background-color: var(--toolbar-button-hover-background);
+}
+
+/* Shapes highlighter */
+
+.shapes-root {
+ pointer-events: none;
+}
+
+.shapes-shape-container {
+ position: absolute;
+ overflow: visible;
+}
+
+.shapes-polygon,
+.shapes-ellipse,
+.shapes-rect,
+.shapes-bounding-box,
+.shapes-rotate-line,
+.shapes-quad {
+ fill: transparent;
+ stroke: var(--highlighter-guide-color);
+ shape-rendering: geometricPrecision;
+ vector-effect: non-scaling-stroke;
+}
+
+.shapes-markers {
+ fill: #fff;
+}
+
+.shapes-markers-outline {
+ fill: var(--highlighter-guide-color);
+}
+
+.shapes-marker-hover {
+ fill: var(--highlighter-guide-color);
+}
+
+/* Accessible highlighter */
+
+.accessible-infobar {
+ min-width: unset;
+}
+
+.accessible-infobar-text {
+ display: grid;
+ grid-template-areas:
+ "role name"
+ "audit audit";
+ grid-template-columns: min-content 1fr;
+}
+
+.accessible-infobar-role {
+ grid-area: role;
+ color: #9cdcfe;
+}
+
+.accessible-infobar-name {
+ grid-area: name;
+}
+
+.accessible-infobar-audit {
+ grid-area: audit;
+ padding-top: 5px;
+ padding-bottom: 2px;
+}
+
+.accessible-bounds {
+ fill: var(--highlighter-accessibility-bounds-color);
+ opacity: var(--highlighter-accessibility-bounds-opacity);
+}
+
+@media (prefers-reduced-motion) {
+ .use-simple-highlighters .accessible-bounds {
+ fill: none;
+ stroke: var(--highlighter-accessibility-bounds-color);
+ stroke-width: 3;
+ }
+}
+
+.accessible-infobar-name,
+.accessible-infobar-audit {
+ color: var(--highlighter-infobar-color);
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio:empty::before,
+.accessible-infobar-audit .accessible-contrast-ratio:empty::after,
+.accessible-infobar-name:empty {
+ display: none;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio::before {
+ content: "";
+ height: 8px;
+ width: 8px;
+ display: inline-flex;
+ background-color: var(--accessibility-highlighter-contrast-ratio-color);
+ box-shadow: 0 0 0 1px var(--grey-40),
+ 4px 3px var(--accessibility-highlighter-contrast-ratio-bg),
+ 4px 3px 0 1px var(--grey-40);
+ margin-inline-start: 3px;
+ margin-inline-end: 9px;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio::after {
+ margin-inline-start: 2px;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.AA::after,
+.accessible-infobar-audit .accessible-contrast-ratio.AAA::after {
+ color: #90E274;
+}
+
+.accessible-infobar-audit .accessible-audit::before,
+.accessible-infobar-audit .accessible-contrast-ratio.FAIL::after {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ content: "";
+ vertical-align: -2px;
+ background-position: center;
+ background-repeat: no-repeat;
+ -moz-context-properties: fill;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.FAIL:after {
+ color: #E57180;
+ margin-inline-start: 3px;
+ background-image: url(resource://devtools-shared-images/error-small.svg);
+ fill: var(--red-40);
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.AA::after {
+ content: "AA\2713";
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio.AAA::after {
+ content: "AAA\2713";
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio-label,
+.accessible-infobar-audit .accessible-contrast-ratio-separator::before {
+ margin-inline-end: 3px;
+}
+
+.accessible-infobar-audit .accessible-contrast-ratio-separator::before {
+ content: "-";
+ margin-inline-start: 3px;
+}
+
+.accessible-infobar-audit .accessible-audit {
+ display: block;
+ padding-block-end: 5px;
+}
+
+.accessible-infobar-audit .accessible-audit:last-child {
+ padding-block-end: 0;
+}
+
+.accessible-infobar-audit .accessible-audit::before {
+ margin-inline-end: 4px;
+ background-image: none;
+ fill: currentColor;
+}
+
+.accessible-infobar-audit .accessible-audit.FAIL::before {
+ background-image: url(resource://devtools-shared-images/error-small.svg);
+ fill: var(--red-40);
+}
+
+.accessible-infobar-audit .accessible-audit.WARNING::before {
+ background-image: url(chrome://devtools/skin/images/alert-small.svg);
+ fill: var(--yellow-60);
+}
+
+.accessible-infobar-audit .accessible-audit.BEST_PRACTICES::before {
+ background-image: url(chrome://devtools/skin/images/info-small.svg);
+}
+
+.accessible-infobar-name {
+ border-inline-start: 1px solid #5a6169;
+ margin-inline-start: 6px;
+ padding-inline-start: 6px;
+}
+
+/* Tabbing-order highlighter */
+
+.tabbing-order-infobar {
+ min-width: unset;
+}
+
+.tabbing-order .tabbing-order-infobar-container {
+ font-size:calc(var(--highlighter-font-size) + 2px);
+}
+
+.tabbing-order .tabbing-order-bounds {
+ position: absolute;
+ display: block;
+ outline: 2px solid #000;
+ outline-offset: -2px;
+}
+
+.tabbing-order.focused .tabbing-order-bounds {
+ outline-color: var(--blue-60);
+}
+
+.tabbing-order.focused .tabbing-order-infobar {
+ background-color: var(--blue-60);
+}
+
+.tabbing-order.focused .tabbing-order-infobar-text {
+ text-decoration: underline;
+}
+
+.tabbing-order.focused .tabbing-order-infobar:after {
+ border-top-color: var(--blue-60);
+ border-bottom-color: var(--blue-60);
+}
diff --git a/devtools/server/actors/highlighters/css/moz.build b/devtools/server/actors/highlighters/css/moz.build
new file mode 100644
index 0000000000..6bdf0f9579
--- /dev/null
+++ b/devtools/server/actors/highlighters/css/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(
+ "highlighters.css",
+)
diff --git a/devtools/server/actors/highlighters/eye-dropper.js b/devtools/server/actors/highlighters/eye-dropper.js
new file mode 100644
index 0000000000..8a206bc84f
--- /dev/null
+++ b/devtools/server/actors/highlighters/eye-dropper.js
@@ -0,0 +1,608 @@
+/* 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";
+
+// Eye-dropper tool. This is implemented as a highlighter so it can be displayed in the
+// content page.
+// It basically displays a magnifier that tracks mouse moves and shows a magnified version
+// of the page. On click, it samples the color at the pixel being hovered.
+
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const { rgbToHsl } =
+ require("resource://devtools/shared/css/color.js").colorUtils;
+const {
+ getCurrentZoom,
+ getFrameOffsets,
+} = require("resource://devtools/shared/layout/utils.js");
+
+loader.lazyGetter(this, "clipboardHelper", () =>
+ Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper)
+);
+loader.lazyGetter(this, "l10n", () =>
+ Services.strings.createBundle(
+ "chrome://devtools-shared/locale/eyedropper.properties"
+ )
+);
+
+const ZOOM_LEVEL_PREF = "devtools.eyedropper.zoom";
+const FORMAT_PREF = "devtools.defaultColorUnit";
+// Width of the canvas.
+const MAGNIFIER_WIDTH = 96;
+// Height of the canvas.
+const MAGNIFIER_HEIGHT = 96;
+// Start position, when the tool is first shown. This should match the top/left position
+// defined in CSS.
+const DEFAULT_START_POS_X = 100;
+const DEFAULT_START_POS_Y = 100;
+// How long to wait before closing after copy.
+const CLOSE_DELAY = 750;
+
+/**
+ * The EyeDropper allows the user to select a color of a pixel within the content page,
+ * showing a magnified circle and color preview while the user hover the page.
+ */
+class EyeDropper {
+ #pageEventListenersAbortController;
+ constructor(highlighterEnv) {
+ EventEmitter.decorate(this);
+
+ this.highlighterEnv = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ // Get a couple of settings from prefs.
+ this.format = Services.prefs.getCharPref(FORMAT_PREF);
+ this.eyeDropperZoomLevel = Services.prefs.getIntPref(ZOOM_LEVEL_PREF);
+ }
+
+ ID_CLASS_PREFIX = "eye-dropper-";
+
+ get win() {
+ return this.highlighterEnv.window;
+ }
+
+ _buildMarkup() {
+ // Highlighter main container.
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ // Wrapper element.
+ const wrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // The magnifier canvas element.
+ this.markup.createNode({
+ parent: wrapper,
+ nodeType: "canvas",
+ attributes: {
+ id: "canvas",
+ class: "canvas",
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // The color label element.
+ const colorLabelContainer = this.markup.createNode({
+ parent: wrapper,
+ attributes: { class: "color-container" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "div",
+ parent: colorLabelContainer,
+ attributes: { id: "color-preview", class: "color-preview" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ this.markup.createNode({
+ nodeType: "div",
+ parent: colorLabelContainer,
+ attributes: { id: "color-value", class: "color-value" },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ destroy() {
+ this.hide();
+ this.markup.destroy();
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Show the eye-dropper highlighter.
+ *
+ * @param {DOMNode} node The node which document the highlighter should be inserted in.
+ * @param {Object} options The options object may contain the following properties:
+ * - {Boolean} copyOnSelect: Whether selecting a color should copy it to the clipboard.
+ * - {String|null} screenshot: a dataURL representation of the page screenshot. If null,
+ * the eyedropper will use `drawWindow` to get the the screenshot
+ * (⚠️ but it won't handle remote frames).
+ */
+ show(node, options = {}) {
+ if (this.highlighterEnv.isXUL) {
+ return false;
+ }
+
+ this.options = options;
+
+ // Get the page's current zoom level.
+ this.pageZoom = getCurrentZoom(this.win);
+
+ // Take a screenshot of the viewport. This needs to be done first otherwise the
+ // eyedropper UI will appear in the screenshot itself (since the UI is injected as
+ // native anonymous content in the page).
+ // Once the screenshot is ready, the magnified area will be drawn.
+ this.prepareImageCapture(options.screenshot);
+
+ // Start listening for user events.
+ const { pageListenerTarget } = this.highlighterEnv;
+ this.#pageEventListenersAbortController = new AbortController();
+ const signal = this.#pageEventListenersAbortController.signal;
+ pageListenerTarget.addEventListener("mousemove", this, { signal });
+ pageListenerTarget.addEventListener("click", this, {
+ signal,
+ useCapture: true,
+ });
+ pageListenerTarget.addEventListener("keydown", this, { signal });
+ pageListenerTarget.addEventListener("DOMMouseScroll", this, { signal });
+ pageListenerTarget.addEventListener("FullZoomChange", this, { signal });
+
+ // Show the eye-dropper.
+ this.getElement("root").removeAttribute("hidden");
+
+ // Prepare the canvas context on which we're drawing the magnified page portion.
+ this.ctx = this.getElement("canvas").getCanvasContext();
+ this.ctx.imageSmoothingEnabled = false;
+
+ this.magnifiedArea = {
+ width: MAGNIFIER_WIDTH,
+ height: MAGNIFIER_HEIGHT,
+ x: DEFAULT_START_POS_X,
+ y: DEFAULT_START_POS_Y,
+ };
+
+ this.moveTo(DEFAULT_START_POS_X, DEFAULT_START_POS_Y);
+
+ // Focus the content so the keyboard can be used.
+ this.win.focus();
+
+ // Make sure we receive mouse events when the debugger has paused execution
+ // in the page.
+ this.win.document.setSuppressedEventListener(this);
+
+ return true;
+ }
+
+ /**
+ * Hide the eye-dropper highlighter.
+ */
+ hide() {
+ this.pageImage = null;
+
+ if (this.#pageEventListenersAbortController) {
+ this.#pageEventListenersAbortController.abort();
+ this.#pageEventListenersAbortController = null;
+
+ const rootElement = this.getElement("root");
+ rootElement.setAttribute("hidden", "true");
+ rootElement.removeAttribute("drawn");
+
+ this.emit("hidden");
+
+ this.win.document.setSuppressedEventListener(null);
+ }
+ }
+
+ /**
+ * Convert a base64 png data-uri to raw binary data.
+ */
+ #dataURItoBlob(dataURI) {
+ const byteString = atob(dataURI.split(",")[1]);
+
+ // write the bytes of the string to an ArrayBuffer
+ const buffer = new ArrayBuffer(byteString.length);
+ // Update the buffer through a typed array.
+ const typedArray = new Uint8Array(buffer);
+ for (let i = 0; i < byteString.length; i++) {
+ typedArray[i] = byteString.charCodeAt(i);
+ }
+
+ return new Blob([buffer], { type: "image/png" });
+ }
+
+ /**
+ * Create an image bitmap from the page screenshot, draw the eyedropper and set the
+ * "drawn" attribute on the "root" element once it's done.
+ *
+ * @params {String|null} screenshot: a dataURL representation of the page screenshot.
+ * If null, we'll use `drawWindow` to get the the page screenshot
+ * (⚠️ but it won't handle remote frames).
+ */
+ async prepareImageCapture(screenshot) {
+ let imageSource;
+ if (screenshot) {
+ imageSource = this.#dataURItoBlob(screenshot);
+ } else {
+ imageSource = getWindowAsImageData(this.win);
+ }
+
+ // We need to transform the blob/imageData to something drawWindow will consume.
+ // An ImageBitmap works well. We could have used an Image, but doing so results
+ // in errors if the page defines CSP headers.
+ const image = await this.win.createImageBitmap(imageSource);
+
+ this.pageImage = image;
+ // We likely haven't drawn anything yet (no mousemove events yet), so start now.
+ this.draw();
+
+ // Set an attribute on the root element to be able to run tests after the first draw
+ // was done.
+ this.getElement("root").setAttribute("drawn", "true");
+ }
+
+ /**
+ * Get the number of cells (blown-up pixels) per direction in the grid.
+ */
+ get cellsWide() {
+ // Canvas will render whole "pixels" (cells) only, and an even number at that. Round
+ // up to the nearest even number of pixels.
+ let cellsWide = Math.ceil(
+ this.magnifiedArea.width / this.eyeDropperZoomLevel
+ );
+ cellsWide += cellsWide % 2;
+
+ return cellsWide;
+ }
+
+ /**
+ * Get the size of each cell (blown-up pixel) in the grid.
+ */
+ get cellSize() {
+ return this.magnifiedArea.width / this.cellsWide;
+ }
+
+ /**
+ * Get index of cell in the center of the grid.
+ */
+ get centerCell() {
+ return Math.floor(this.cellsWide / 2);
+ }
+
+ /**
+ * Get color of center cell in the grid.
+ */
+ get centerColor() {
+ const pos = this.centerCell * this.cellSize + this.cellSize / 2;
+ const rgb = this.ctx.getImageData(pos, pos, 1, 1).data;
+ return rgb;
+ }
+
+ draw() {
+ // If the image of the page isn't ready yet, bail out, we'll draw later on mousemove.
+ if (!this.pageImage) {
+ return;
+ }
+
+ const { width, height, x, y } = this.magnifiedArea;
+
+ const zoomedWidth = width / this.eyeDropperZoomLevel;
+ const zoomedHeight = height / this.eyeDropperZoomLevel;
+
+ const sx = x - zoomedWidth / 2;
+ const sy = y - zoomedHeight / 2;
+ const sw = zoomedWidth;
+ const sh = zoomedHeight;
+
+ this.ctx.drawImage(this.pageImage, sx, sy, sw, sh, 0, 0, width, height);
+
+ // Draw the grid on top, but only at 3x or more, otherwise it's too busy.
+ if (this.eyeDropperZoomLevel > 2) {
+ this.drawGrid();
+ }
+
+ this.drawCrosshair();
+
+ // Update the color preview and value.
+ const rgb = this.centerColor;
+ this.getElement("color-preview").setAttribute(
+ "style",
+ `background-color:${toColorString(rgb, "rgb")};`
+ );
+ this.getElement("color-value").setTextContent(
+ toColorString(rgb, this.format)
+ );
+ }
+
+ /**
+ * Draw a grid on the canvas representing pixel boundaries.
+ */
+ drawGrid() {
+ const { width, height } = this.magnifiedArea;
+
+ this.ctx.lineWidth = 1;
+ this.ctx.strokeStyle = "rgba(143, 143, 143, 0.2)";
+
+ for (let i = 0; i < width; i += this.cellSize) {
+ this.ctx.beginPath();
+ this.ctx.moveTo(i - 0.5, 0);
+ this.ctx.lineTo(i - 0.5, height);
+ this.ctx.stroke();
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(0, i - 0.5);
+ this.ctx.lineTo(width, i - 0.5);
+ this.ctx.stroke();
+ }
+ }
+
+ /**
+ * Draw a box on the canvas to highlight the center cell.
+ */
+ drawCrosshair() {
+ const pos = this.centerCell * this.cellSize;
+
+ this.ctx.lineWidth = 1;
+ this.ctx.lineJoin = "miter";
+ this.ctx.strokeStyle = "rgba(0, 0, 0, 1)";
+ this.ctx.strokeRect(
+ pos - 1.5,
+ pos - 1.5,
+ this.cellSize + 2,
+ this.cellSize + 2
+ );
+
+ this.ctx.strokeStyle = "rgba(255, 255, 255, 1)";
+ this.ctx.strokeRect(pos - 0.5, pos - 0.5, this.cellSize, this.cellSize);
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "mousemove":
+ // We might be getting an event from a child frame, so account for the offset.
+ const [xOffset, yOffset] = getFrameOffsets(this.win, e.target);
+ const x = xOffset + e.pageX - this.win.scrollX;
+ const y = yOffset + e.pageY - this.win.scrollY;
+ // Update the zoom area.
+ this.magnifiedArea.x = x * this.pageZoom;
+ this.magnifiedArea.y = y * this.pageZoom;
+ // Redraw the portion of the screenshot that is now under the mouse.
+ this.draw();
+ // And move the eye-dropper's UI so it follows the mouse.
+ this.moveTo(x, y);
+ break;
+ // Note: when events are suppressed we will only get mousedown/mouseup and
+ // not any click events.
+ case "click":
+ case "mouseup":
+ this.selectColor();
+ break;
+ case "keydown":
+ this.handleKeyDown(e);
+ break;
+ case "DOMMouseScroll":
+ // Prevent scrolling. That's because we only took a screenshot of the viewport, so
+ // scrolling out of the viewport wouldn't draw the expected things. In the future
+ // we can take the screenshot again on scroll, but for now it doesn't seem
+ // important.
+ e.preventDefault();
+ break;
+ case "FullZoomChange":
+ this.hide();
+ this.show();
+ break;
+ }
+ }
+
+ moveTo(x, y) {
+ const root = this.getElement("root");
+ root.setAttribute("style", `top:${y}px;left:${x}px;`);
+
+ // Move the label container to the top if the magnifier is close to the bottom edge.
+ if (y >= this.win.innerHeight - MAGNIFIER_HEIGHT) {
+ root.setAttribute("top", "");
+ } else {
+ root.removeAttribute("top");
+ }
+
+ // Also offset the label container to the right or left if the magnifier is close to
+ // the edge.
+ root.removeAttribute("left");
+ root.removeAttribute("right");
+ if (x <= MAGNIFIER_WIDTH) {
+ root.setAttribute("right", "");
+ } else if (x >= this.win.innerWidth - MAGNIFIER_WIDTH) {
+ root.setAttribute("left", "");
+ }
+ }
+
+ /**
+ * Select the current color that's being previewed. Depending on the current options,
+ * selecting might mean copying to the clipboard and closing the
+ */
+ selectColor() {
+ let onColorSelected = Promise.resolve();
+ if (this.options.copyOnSelect) {
+ onColorSelected = this.copyColor();
+ }
+
+ this.emit("selected", toColorString(this.centerColor, this.format));
+ onColorSelected.then(() => this.hide(), console.error);
+ }
+
+ /**
+ * Handler for the keydown event. Either select the color or move the panel in a
+ * direction depending on the key pressed.
+ */
+ handleKeyDown(e) {
+ // Bail out early if any unsupported modifier is used, so that we let
+ // keyboard shortcuts through.
+ if (e.metaKey || e.ctrlKey || e.altKey) {
+ return;
+ }
+
+ if (e.keyCode === e.DOM_VK_RETURN) {
+ this.selectColor();
+ e.preventDefault();
+ return;
+ }
+
+ if (e.keyCode === e.DOM_VK_ESCAPE) {
+ this.emit("canceled");
+ this.hide();
+ e.preventDefault();
+ return;
+ }
+
+ let offsetX = 0;
+ let offsetY = 0;
+ let modifier = 1;
+
+ if (e.keyCode === e.DOM_VK_LEFT) {
+ offsetX = -1;
+ } else if (e.keyCode === e.DOM_VK_RIGHT) {
+ offsetX = 1;
+ } else if (e.keyCode === e.DOM_VK_UP) {
+ offsetY = -1;
+ } else if (e.keyCode === e.DOM_VK_DOWN) {
+ offsetY = 1;
+ }
+
+ if (e.shiftKey) {
+ modifier = 10;
+ }
+
+ offsetY *= modifier;
+ offsetX *= modifier;
+
+ if (offsetX !== 0 || offsetY !== 0) {
+ this.magnifiedArea.x = cap(
+ this.magnifiedArea.x + offsetX,
+ 0,
+ this.win.innerWidth * this.pageZoom
+ );
+ this.magnifiedArea.y = cap(
+ this.magnifiedArea.y + offsetY,
+ 0,
+ this.win.innerHeight * this.pageZoom
+ );
+
+ this.draw();
+
+ this.moveTo(
+ this.magnifiedArea.x / this.pageZoom,
+ this.magnifiedArea.y / this.pageZoom
+ );
+
+ e.preventDefault();
+ }
+ }
+
+ /**
+ * Copy the currently inspected color to the clipboard.
+ * @return {Promise} Resolves when the copy has been done (after a delay that is used to
+ * let users know that something was copied).
+ */
+ copyColor() {
+ // Copy to the clipboard.
+ const color = toColorString(this.centerColor, this.format);
+ clipboardHelper.copyString(color);
+
+ // Provide some feedback.
+ this.getElement("color-value").setTextContent(
+ "✓ " + l10n.GetStringFromName("colorValue.copied")
+ );
+
+ // Hide the tool after a delay.
+ clearTimeout(this._copyTimeout);
+ return new Promise(resolve => {
+ this._copyTimeout = setTimeout(resolve, CLOSE_DELAY);
+ });
+ }
+}
+
+exports.EyeDropper = EyeDropper;
+
+/**
+ * Draw the visible portion of the window on a canvas and get the resulting ImageData.
+ * @param {Window} win
+ * @return {ImageData} The image data for the window.
+ */
+function getWindowAsImageData(win) {
+ const canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ const scale = getCurrentZoom(win);
+ const width = win.innerWidth;
+ const height = win.innerHeight;
+ canvas.width = width * scale;
+ canvas.height = height * scale;
+ canvas.mozOpaque = true;
+
+ const ctx = canvas.getContext("2d");
+
+ ctx.scale(scale, scale);
+ ctx.drawWindow(win, win.scrollX, win.scrollY, width, height, "#fff");
+
+ return ctx.getImageData(0, 0, canvas.width, canvas.height);
+}
+
+/**
+ * Get a formatted CSS color string from a color value.
+ * @param {array} rgb Rgb values of a color to format.
+ * @param {string} format Format of string. One of "hex", "rgb", "hsl", "name".
+ * @return {string} Formatted color value, e.g. "#FFF" or "hsl(20, 10%, 10%)".
+ */
+function toColorString(rgb, format) {
+ const [r, g, b] = rgb;
+
+ switch (format) {
+ case "hex":
+ return hexString(rgb);
+ case "rgb":
+ return "rgb(" + r + ", " + g + ", " + b + ")";
+ case "hsl":
+ const [h, s, l] = rgbToHsl(rgb);
+ return "hsl(" + h + ", " + s + "%, " + l + "%)";
+ case "name":
+ const str = InspectorUtils.rgbToColorName(r, g, b) || hexString(rgb);
+ return str;
+ default:
+ return hexString(rgb);
+ }
+}
+
+/**
+ * Produce a hex-formatted color string from rgb values.
+ * @param {array} rgb Rgb values of color to stringify.
+ * @return {string} Hex formatted string for color, e.g. "#FFEE00".
+ */
+function hexString([r, g, b]) {
+ const val = (1 << 24) + (r << 16) + (g << 8) + (b << 0);
+ return "#" + val.toString(16).substr(-6);
+}
+
+function cap(value, min, max) {
+ return Math.max(min, Math.min(value, max));
+}
diff --git a/devtools/server/actors/highlighters/flexbox.js b/devtools/server/actors/highlighters/flexbox.js
new file mode 100644
index 0000000000..820e4f8a73
--- /dev/null
+++ b/devtools/server/actors/highlighters/flexbox.js
@@ -0,0 +1,1033 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const { apply } = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const {
+ CANVAS_SIZE,
+ DEFAULT_COLOR,
+ clearRect,
+ drawLine,
+ drawRect,
+ getCurrentMatrix,
+ updateCanvasElement,
+ updateCanvasPosition,
+} = require("resource://devtools/server/actors/highlighters/utils/canvas.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ getAbsoluteScrollOffsetsForNode,
+ getCurrentZoom,
+ getDisplayPixelRatio,
+ getUntransformedQuad,
+ getWindowDimensions,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+
+const FLEXBOX_LINES_PROPERTIES = {
+ edge: {
+ lineDash: [5, 3],
+ },
+ item: {
+ lineDash: [0, 0],
+ },
+ alignItems: {
+ lineDash: [0, 0],
+ },
+};
+
+const FLEXBOX_CONTAINER_PATTERN_LINE_DASH = [5, 3]; // px
+const FLEXBOX_CONTAINER_PATTERN_WIDTH = 14; // px
+const FLEXBOX_CONTAINER_PATTERN_HEIGHT = 14; // px
+const FLEXBOX_JUSTIFY_CONTENT_PATTERN_WIDTH = 7; // px
+const FLEXBOX_JUSTIFY_CONTENT_PATTERN_HEIGHT = 7; // px
+
+/**
+ * Cached used by `FlexboxHighlighter.getFlexContainerPattern`.
+ */
+const gCachedFlexboxPattern = new Map();
+
+const FLEXBOX = "flexbox";
+const JUSTIFY_CONTENT = "justify-content";
+
+/**
+ * The FlexboxHighlighter is the class that overlays a visual canvas on top of
+ * display: [inline-]flex elements.
+ *
+ * @param {String} options.color
+ * The color that should be used to draw the highlighter for this flexbox.
+ * Structure:
+ * <div class="highlighter-container">
+ * <div id="flexbox-root" class="flexbox-root">
+ * <canvas id="flexbox-canvas"
+ * class="flexbox-canvas"
+ * width="4096"
+ * height="4096"
+ * hidden="true">
+ * </canvas>
+ * </div>
+ * </div>
+ */
+class FlexboxHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "flexbox-";
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+
+ // Initialize the <canvas> position to the top left corner of the page
+ this._canvasPosition = {
+ x: 0,
+ y: 0,
+ };
+
+ this._ignoreZoom = true;
+
+ // Calling `updateCanvasPosition` anyway since the highlighter could be initialized
+ // on a page that has scrolled already.
+ updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // We use a <canvas> element because there is an arbitrary number of items and texts
+ // to draw which wouldn't be possible with HTML or SVG without having to insert and
+ // remove the whole markup on every update.
+ this.markup.createNode({
+ parent: root,
+ nodeType: "canvas",
+ attributes: {
+ id: "canvas",
+ class: "canvas",
+ hidden: "true",
+ width: CANVAS_SIZE,
+ height: CANVAS_SIZE,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ clearCache() {
+ gCachedFlexboxPattern.clear();
+ }
+
+ destroy() {
+ const { highlighterEnv } = this;
+ highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.markup.destroy();
+
+ // Clear the pattern cache to avoid dead object exceptions (Bug 1342051).
+ this.clearCache();
+
+ this.axes = null;
+ this.crossAxisDirection = null;
+ this.flexData = null;
+ this.mainAxisDirection = null;
+ this.transform = null;
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ /**
+ * Draw the justify content for a given flex item (left, top, right, bottom) position.
+ */
+ drawJustifyContent(left, top, right, bottom) {
+ const { devicePixelRatio } = this.win;
+ this.ctx.fillStyle = this.getJustifyContentPattern(devicePixelRatio);
+ drawRect(this.ctx, left, top, right, bottom, this.currentMatrix);
+ this.ctx.fill();
+ }
+
+ get canvas() {
+ return this.getElement("canvas");
+ }
+
+ get color() {
+ return this.options.color || DEFAULT_COLOR;
+ }
+
+ get container() {
+ return this.currentNode;
+ }
+
+ get ctx() {
+ return this.canvas.getCanvasContext("2d");
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Gets the flexbox container pattern used to render the container regions.
+ *
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio we want the pattern for.
+ * @return {CanvasPattern} flex container pattern.
+ */
+ getFlexContainerPattern(devicePixelRatio) {
+ let flexboxPatternMap = null;
+
+ if (gCachedFlexboxPattern.has(devicePixelRatio)) {
+ flexboxPatternMap = gCachedFlexboxPattern.get(devicePixelRatio);
+ } else {
+ flexboxPatternMap = new Map();
+ }
+
+ if (gCachedFlexboxPattern.has(FLEXBOX)) {
+ return gCachedFlexboxPattern.get(FLEXBOX);
+ }
+
+ // Create the diagonal lines pattern for the rendering the flexbox gaps.
+ const canvas = this.markup.createNode({ nodeType: "canvas" });
+ const width = (canvas.width =
+ FLEXBOX_CONTAINER_PATTERN_WIDTH * devicePixelRatio);
+ const height = (canvas.height =
+ FLEXBOX_CONTAINER_PATTERN_HEIGHT * devicePixelRatio);
+
+ const ctx = canvas.getContext("2d");
+ ctx.save();
+ ctx.setLineDash(FLEXBOX_CONTAINER_PATTERN_LINE_DASH);
+ ctx.beginPath();
+ ctx.translate(0.5, 0.5);
+
+ ctx.moveTo(0, 0);
+ ctx.lineTo(width, height);
+
+ ctx.strokeStyle = this.color;
+ ctx.stroke();
+ ctx.restore();
+
+ const pattern = ctx.createPattern(canvas, "repeat");
+ flexboxPatternMap.set(FLEXBOX, pattern);
+ gCachedFlexboxPattern.set(devicePixelRatio, flexboxPatternMap);
+
+ return pattern;
+ }
+
+ /**
+ * Gets the flexbox justify content pattern used to render the justify content regions.
+ *
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio we want the pattern for.
+ * @return {CanvasPattern} flex justify content pattern.
+ */
+ getJustifyContentPattern(devicePixelRatio) {
+ let flexboxPatternMap = null;
+
+ if (gCachedFlexboxPattern.has(devicePixelRatio)) {
+ flexboxPatternMap = gCachedFlexboxPattern.get(devicePixelRatio);
+ } else {
+ flexboxPatternMap = new Map();
+ }
+
+ if (flexboxPatternMap.has(JUSTIFY_CONTENT)) {
+ return flexboxPatternMap.get(JUSTIFY_CONTENT);
+ }
+
+ // Create the inversed diagonal lines pattern
+ // for the rendering the justify content gaps.
+ const canvas = this.markup.createNode({ nodeType: "canvas" });
+ const zoom = getCurrentZoom(this.win);
+ const width = (canvas.width =
+ FLEXBOX_JUSTIFY_CONTENT_PATTERN_WIDTH * devicePixelRatio * zoom);
+ const height = (canvas.height =
+ FLEXBOX_JUSTIFY_CONTENT_PATTERN_HEIGHT * devicePixelRatio * zoom);
+
+ const ctx = canvas.getContext("2d");
+ ctx.save();
+ ctx.setLineDash(FLEXBOX_CONTAINER_PATTERN_LINE_DASH);
+ ctx.beginPath();
+ ctx.translate(0.5, 0.5);
+
+ ctx.moveTo(0, height);
+ ctx.lineTo(width, 0);
+
+ ctx.strokeStyle = this.color;
+ ctx.stroke();
+ ctx.restore();
+
+ const pattern = ctx.createPattern(canvas, "repeat");
+ flexboxPatternMap.set(JUSTIFY_CONTENT, pattern);
+ gCachedFlexboxPattern.set(devicePixelRatio, flexboxPatternMap);
+
+ return pattern;
+ }
+
+ /**
+ * The AutoRefreshHighlighter's _hasMoved method returns true only if the
+ * element's quads have changed. Override it so it also returns true if the
+ * flex container and its flex items have changed.
+ */
+ _hasMoved() {
+ const hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
+
+ if (!this.computedStyle) {
+ this.computedStyle = getComputedStyle(this.container);
+ }
+
+ const flex = this.container.getAsFlexContainer();
+
+ const oldCrossAxisDirection = this.crossAxisDirection;
+ this.crossAxisDirection = flex ? flex.crossAxisDirection : null;
+ const newCrossAxisDirection = this.crossAxisDirection;
+
+ const oldMainAxisDirection = this.mainAxisDirection;
+ this.mainAxisDirection = flex ? flex.mainAxisDirection : null;
+ const newMainAxisDirection = this.mainAxisDirection;
+
+ // Concatenate the axes to simplify conditionals.
+ this.axes = `${this.mainAxisDirection} ${this.crossAxisDirection}`;
+
+ const oldFlexData = this.flexData;
+ this.flexData = getFlexData(this.container);
+ const hasFlexDataChanged = compareFlexData(oldFlexData, this.flexData);
+
+ const oldAlignItems = this.alignItemsValue;
+ this.alignItemsValue = this.computedStyle.alignItems;
+ const newAlignItems = this.alignItemsValue;
+
+ const oldFlexDirection = this.flexDirection;
+ this.flexDirection = this.computedStyle.flexDirection;
+ const newFlexDirection = this.flexDirection;
+
+ const oldFlexWrap = this.flexWrap;
+ this.flexWrap = this.computedStyle.flexWrap;
+ const newFlexWrap = this.flexWrap;
+
+ const oldJustifyContent = this.justifyContentValue;
+ this.justifyContentValue = this.computedStyle.justifyContent;
+ const newJustifyContent = this.justifyContentValue;
+
+ const oldTransform = this.transformValue;
+ this.transformValue = this.computedStyle.transform;
+ const newTransform = this.transformValue;
+
+ return (
+ hasMoved ||
+ hasFlexDataChanged ||
+ oldAlignItems !== newAlignItems ||
+ oldFlexDirection !== newFlexDirection ||
+ oldFlexWrap !== newFlexWrap ||
+ oldJustifyContent !== newJustifyContent ||
+ oldCrossAxisDirection !== newCrossAxisDirection ||
+ oldMainAxisDirection !== newMainAxisDirection ||
+ oldTransform !== newTransform
+ );
+ }
+
+ _hide() {
+ this.alignItemsValue = null;
+ this.computedStyle = null;
+ this.flexData = null;
+ this.flexDirection = null;
+ this.flexWrap = null;
+ this.justifyContentValue = null;
+
+ setIgnoreLayoutChanges(true);
+ this._hideFlexbox();
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ }
+
+ _hideFlexbox() {
+ this.getElement("canvas").setAttribute("hidden", "true");
+ }
+
+ /**
+ * The <canvas>'s position needs to be updated if the page scrolls too much, in order
+ * to give the illusion that it always covers the viewport.
+ */
+ _scrollUpdate() {
+ const hasUpdated = updateCanvasPosition(
+ this._canvasPosition,
+ this._scroll,
+ this.win,
+ this._winDimensions
+ );
+
+ if (hasUpdated) {
+ this._update();
+ }
+ }
+
+ _show() {
+ this._hide();
+ return this._update();
+ }
+
+ _showFlexbox() {
+ this.getElement("canvas").removeAttribute("hidden");
+ }
+
+ /**
+ * If a page hide event is triggered for current window's highlighter, hide the
+ * highlighter.
+ */
+ onPageHide({ target }) {
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Called when the page will-navigate. Used to hide the flexbox highlighter and clear
+ * the cached gap patterns and avoid using DeadWrapper obejcts as gap patterns the
+ * next time.
+ */
+ onWillNavigate({ isTopLevel }) {
+ this.clearCache();
+
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+
+ renderFlexContainer() {
+ if (!this.currentQuads.content || !this.currentQuads.content[0]) {
+ return;
+ }
+
+ const { devicePixelRatio } = this.win;
+ const containerQuad = getUntransformedQuad(this.container, "content");
+ const { width, height } = containerQuad.getBounds();
+
+ this.setupCanvas({
+ lineDash: FLEXBOX_LINES_PROPERTIES.alignItems.lineDash,
+ lineWidthMultiplier: 2,
+ });
+
+ this.ctx.fillStyle = this.getFlexContainerPattern(devicePixelRatio);
+
+ drawRect(this.ctx, 0, 0, width, height, this.currentMatrix);
+
+ // Find current angle of outer flex element by measuring the angle of two arbitrary
+ // points, then rotate canvas, so the hash pattern stays 45deg to the boundary.
+ const p1 = apply(this.currentMatrix, [0, 0]);
+ const p2 = apply(this.currentMatrix, [1, 0]);
+ const angleRad = Math.atan2(p2[1] - p1[1], p2[0] - p1[0]);
+ this.ctx.rotate(angleRad);
+
+ this.ctx.fill();
+ this.ctx.stroke();
+ this.ctx.restore();
+ }
+
+ renderFlexItems() {
+ if (
+ !this.flexData ||
+ !this.currentQuads.content ||
+ !this.currentQuads.content[0]
+ ) {
+ return;
+ }
+
+ this.setupCanvas({
+ lineDash: FLEXBOX_LINES_PROPERTIES.item.lineDash,
+ });
+
+ for (const flexLine of this.flexData.lines) {
+ for (const flexItem of flexLine.items) {
+ const { left, top, right, bottom } = flexItem.rect;
+
+ clearRect(this.ctx, left, top, right, bottom, this.currentMatrix);
+ drawRect(this.ctx, left, top, right, bottom, this.currentMatrix);
+ this.ctx.stroke();
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ renderFlexLines() {
+ if (
+ !this.flexData ||
+ !this.currentQuads.content ||
+ !this.currentQuads.content[0]
+ ) {
+ return;
+ }
+
+ const lineWidth = getDisplayPixelRatio(this.win);
+ const options = { matrix: this.currentMatrix };
+ const { width: containerWidth, height: containerHeight } =
+ getUntransformedQuad(this.container, "content").getBounds();
+
+ this.setupCanvas({
+ useContainerScrollOffsets: true,
+ });
+
+ for (const flexLine of this.flexData.lines) {
+ const { crossStart, crossSize } = flexLine;
+
+ switch (this.axes) {
+ case "horizontal-lr vertical-tb":
+ case "horizontal-lr vertical-bt":
+ case "horizontal-rl vertical-tb":
+ case "horizontal-rl vertical-bt":
+ clearRect(
+ this.ctx,
+ 0,
+ crossStart,
+ containerWidth,
+ crossStart + crossSize,
+ this.currentMatrix
+ );
+
+ // Avoid drawing the start flex line when they overlap with the flex container.
+ if (crossStart != 0) {
+ drawLine(
+ this.ctx,
+ 0,
+ crossStart,
+ containerWidth,
+ crossStart,
+ options
+ );
+ this.ctx.stroke();
+ }
+
+ // Avoid drawing the end flex line when they overlap with the flex container.
+ if (crossStart + crossSize < containerHeight - lineWidth * 2) {
+ drawLine(
+ this.ctx,
+ 0,
+ crossStart + crossSize,
+ containerWidth,
+ crossStart + crossSize,
+ options
+ );
+ this.ctx.stroke();
+ }
+ break;
+ case "vertical-tb horizontal-lr":
+ case "vertical-bt horizontal-rl":
+ clearRect(
+ this.ctx,
+ crossStart,
+ 0,
+ crossStart + crossSize,
+ containerHeight,
+ this.currentMatrix
+ );
+
+ // Avoid drawing the start flex line when they overlap with the flex container.
+ if (crossStart != 0) {
+ drawLine(
+ this.ctx,
+ crossStart,
+ 0,
+ crossStart,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+
+ // Avoid drawing the end flex line when they overlap with the flex container.
+ if (crossStart + crossSize < containerWidth - lineWidth * 2) {
+ drawLine(
+ this.ctx,
+ crossStart + crossSize,
+ 0,
+ crossStart + crossSize,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+ break;
+ case "vertical-bt horizontal-lr":
+ case "vertical-tb horizontal-rl":
+ clearRect(
+ this.ctx,
+ containerWidth - crossStart,
+ 0,
+ containerWidth - crossStart - crossSize,
+ containerHeight,
+ this.currentMatrix
+ );
+
+ // Avoid drawing the start flex line when they overlap with the flex container.
+ if (crossStart != 0) {
+ drawLine(
+ this.ctx,
+ containerWidth - crossStart,
+ 0,
+ containerWidth - crossStart,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+
+ // Avoid drawing the end flex line when they overlap with the flex container.
+ if (crossStart + crossSize < containerWidth - lineWidth * 2) {
+ drawLine(
+ this.ctx,
+ containerWidth - crossStart - crossSize,
+ 0,
+ containerWidth - crossStart - crossSize,
+ containerHeight,
+ options
+ );
+ this.ctx.stroke();
+ }
+ break;
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ /**
+ * Clear the whole alignment container along the main axis for each flex item.
+ */
+ // eslint-disable-next-line complexity
+ renderJustifyContent() {
+ if (
+ !this.flexData ||
+ !this.currentQuads.content ||
+ !this.currentQuads.content[0]
+ ) {
+ return;
+ }
+
+ const { width: containerWidth, height: containerHeight } =
+ getUntransformedQuad(this.container, "content").getBounds();
+
+ this.setupCanvas({
+ lineDash: FLEXBOX_LINES_PROPERTIES.alignItems.lineDash,
+ offset: (getDisplayPixelRatio(this.win) / 2) % 1,
+ skipLineAndStroke: true,
+ useContainerScrollOffsets: true,
+ });
+
+ for (const flexLine of this.flexData.lines) {
+ const { crossStart, crossSize } = flexLine;
+ let mainStart = 0;
+
+ // In these two situations mainStart goes from right to left so set it's
+ // value as appropriate.
+ if (
+ this.axes === "horizontal-lr vertical-bt" ||
+ this.axes === "horizontal-rl vertical-tb"
+ ) {
+ mainStart = containerWidth;
+ }
+
+ for (const flexItem of flexLine.items) {
+ const { left, top, right, bottom } = flexItem.rect;
+
+ switch (this.axes) {
+ case "horizontal-lr vertical-tb":
+ case "horizontal-rl vertical-bt":
+ this.drawJustifyContent(
+ mainStart,
+ crossStart,
+ left,
+ crossStart + crossSize
+ );
+ mainStart = right;
+ break;
+ case "horizontal-lr vertical-bt":
+ case "horizontal-rl vertical-tb":
+ this.drawJustifyContent(
+ right,
+ crossStart,
+ mainStart,
+ crossStart + crossSize
+ );
+ mainStart = left;
+ break;
+ case "vertical-tb horizontal-lr":
+ case "vertical-bt horizontal-rl":
+ this.drawJustifyContent(
+ crossStart,
+ mainStart,
+ crossStart + crossSize,
+ top
+ );
+ mainStart = bottom;
+ break;
+ case "vertical-bt horizontal-lr":
+ case "vertical-tb horizontal-rl":
+ this.drawJustifyContent(
+ containerWidth - crossStart - crossSize,
+ mainStart,
+ containerWidth - crossStart,
+ top
+ );
+ mainStart = bottom;
+ break;
+ }
+ }
+
+ // Draw the last justify-content area after the last flex item.
+ switch (this.axes) {
+ case "horizontal-lr vertical-tb":
+ case "horizontal-rl vertical-bt":
+ this.drawJustifyContent(
+ mainStart,
+ crossStart,
+ containerWidth,
+ crossStart + crossSize
+ );
+ break;
+ case "horizontal-lr vertical-bt":
+ case "horizontal-rl vertical-tb":
+ this.drawJustifyContent(
+ 0,
+ crossStart,
+ mainStart,
+ crossStart + crossSize
+ );
+ break;
+ case "vertical-tb horizontal-lr":
+ case "vertical-bt horizontal-rl":
+ this.drawJustifyContent(
+ crossStart,
+ mainStart,
+ crossStart + crossSize,
+ containerHeight
+ );
+ break;
+ case "vertical-bt horizontal-lr":
+ case "vertical-tb horizontal-rl":
+ this.drawJustifyContent(
+ containerWidth - crossStart - crossSize,
+ mainStart,
+ containerWidth - crossStart,
+ containerHeight
+ );
+ break;
+ }
+ }
+
+ this.ctx.restore();
+ }
+
+ /**
+ * Set up the canvas with the given options prior to drawing.
+ *
+ * @param {String} [options.lineDash = null]
+ * An Array of numbers that specify distances to alternately draw a
+ * line and a gap (in coordinate space units). If the number of
+ * elements in the array is odd, the elements of the array get copied
+ * and concatenated. For example, [5, 15, 25] will become
+ * [5, 15, 25, 5, 15, 25]. If the array is empty, the line dash list is
+ * cleared and line strokes return to being solid.
+ *
+ * We use the following constants here:
+ * FLEXBOX_LINES_PROPERTIES.edge.lineDash,
+ * FLEXBOX_LINES_PROPERTIES.item.lineDash
+ * FLEXBOX_LINES_PROPERTIES.alignItems.lineDash
+ * @param {Number} [options.lineWidthMultiplier = 1]
+ * The width of the line.
+ * @param {Number} [options.offset = `(displayPixelRatio / 2) % 1`]
+ * The single line width used to obtain a crisp line.
+ * @param {Boolean} [options.skipLineAndStroke = false]
+ * Skip the setting of lineWidth and strokeStyle.
+ * @param {Boolean} [options.useContainerScrollOffsets = false]
+ * Take the flexbox container's scroll and zoom offsets into account.
+ * This is needed for drawing flex lines and justify content when the
+ * flexbox container itself is display:scroll.
+ */
+ setupCanvas({
+ lineDash = null,
+ lineWidthMultiplier = 1,
+ offset = (getDisplayPixelRatio(this.win) / 2) % 1,
+ skipLineAndStroke = false,
+ useContainerScrollOffsets = false,
+ }) {
+ const { devicePixelRatio } = this.win;
+ const lineWidth = getDisplayPixelRatio(this.win);
+ const zoom = getCurrentZoom(this.win);
+ const style = getComputedStyle(this.container);
+ const position = style.position;
+ let offsetX = this._canvasPosition.x;
+ let offsetY = this._canvasPosition.y;
+
+ if (useContainerScrollOffsets) {
+ offsetX += this.container.scrollLeft / zoom;
+ offsetY += this.container.scrollTop / zoom;
+ }
+
+ // If the flexbox container is position:fixed we need to subtract the scroll
+ // positions of all ancestral elements.
+ if (position === "fixed") {
+ const { scrollLeft, scrollTop } = getAbsoluteScrollOffsetsForNode(
+ this.container
+ );
+ offsetX -= scrollLeft / zoom;
+ offsetY -= scrollTop / zoom;
+ }
+
+ const canvasX = Math.round(offsetX * devicePixelRatio * zoom);
+ const canvasY = Math.round(offsetY * devicePixelRatio * zoom);
+
+ this.ctx.save();
+ this.ctx.translate(offset - canvasX, offset - canvasY);
+
+ if (lineDash) {
+ this.ctx.setLineDash(lineDash);
+ }
+
+ if (!skipLineAndStroke) {
+ this.ctx.lineWidth = lineWidth * lineWidthMultiplier;
+ this.ctx.strokeStyle = this.color;
+ }
+ }
+
+ _update() {
+ setIgnoreLayoutChanges(true);
+
+ const root = this.getElement("root");
+
+ // Hide the root element and force the reflow in order to get the proper window's
+ // dimensions without increasing them.
+ root.setAttribute("style", "display: none");
+ this.win.document.documentElement.offsetWidth;
+ this._winDimensions = getWindowDimensions(this.win);
+ const { width, height } = this._winDimensions;
+
+ // Updates the <canvas> element's position and size.
+ // It also clear the <canvas>'s drawing context.
+ updateCanvasElement(
+ this.canvas,
+ this._canvasPosition,
+ this.win.devicePixelRatio,
+ {
+ zoomWindow: this.win,
+ }
+ );
+
+ // Update the current matrix used in our canvas' rendering
+ const { currentMatrix, hasNodeTransformations } = getCurrentMatrix(
+ this.container,
+ this.win,
+ {
+ ignoreWritingModeAndTextDirection: true,
+ }
+ );
+ this.currentMatrix = currentMatrix;
+ this.hasNodeTransformations = hasNodeTransformations;
+
+ if (this.prevColor != this.color) {
+ this.clearCache();
+ }
+ this.renderFlexContainer();
+ this.renderFlexLines();
+ this.renderJustifyContent();
+ this.renderFlexItems();
+ this._showFlexbox();
+ this.prevColor = this.color;
+
+ root.setAttribute(
+ "style",
+ `position: absolute; width: ${width}px; height: ${height}px; overflow: hidden`
+ );
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ return true;
+ }
+}
+
+/**
+ * Returns an object representation of the Flex data object and its array of FlexLine
+ * and FlexItem objects along with the DOMRects of the flex items.
+ *
+ * @param {DOMNode} container
+ * The flex container.
+ * @return {Object|null} representation of the Flex data object.
+ */
+function getFlexData(container) {
+ const flex = container.getAsFlexContainer();
+
+ if (!flex) {
+ return null;
+ }
+
+ return {
+ lines: flex.getLines().map(line => {
+ return {
+ crossSize: line.crossSize,
+ crossStart: line.crossStart,
+ firstBaselineOffset: line.firstBaselineOffset,
+ growthState: line.growthState,
+ lastBaselineOffset: line.lastBaselineOffset,
+ items: line.getItems().map(item => {
+ return {
+ crossMaxSize: item.crossMaxSize,
+ crossMinSize: item.crossMinSize,
+ mainBaseSize: item.mainBaseSize,
+ mainDeltaSize: item.mainDeltaSize,
+ mainMaxSize: item.mainMaxSize,
+ mainMinSize: item.mainMinSize,
+ node: item.node,
+ rect: getRectFromFlexItemValues(item, container),
+ };
+ }),
+ };
+ }),
+ };
+}
+
+/**
+ * Given a FlexItemValues, return a DOMRect representing the flex item taking
+ * into account its flex container's border and padding.
+ *
+ * @param {FlexItemValues} item
+ * The FlexItemValues for which we need the DOMRect.
+ * @param {DOMNode}
+ * Flex container containing the flex item.
+ * @return {DOMRect} representing the flex item.
+ */
+function getRectFromFlexItemValues(item, container) {
+ const rect = item.frameRect;
+ const domRect = new DOMRect(rect.x, rect.y, rect.width, rect.height);
+ const win = container.ownerGlobal;
+ const style = win.getComputedStyle(container);
+ const borderLeftWidth = parseInt(style.borderLeftWidth, 10) || 0;
+ const borderTopWidth = parseInt(style.borderTopWidth, 10) || 0;
+ const paddingLeft = parseInt(style.paddingLeft, 10) || 0;
+ const paddingTop = parseInt(style.paddingTop, 10) || 0;
+ const scrollX = container.scrollLeft || 0;
+ const scrollY = container.scrollTop || 0;
+
+ domRect.x -= paddingLeft + scrollX;
+ domRect.y -= paddingTop + scrollY;
+
+ if (style.overflow === "visible" || style.overflow === "clip") {
+ domRect.x -= borderLeftWidth;
+ domRect.y -= borderTopWidth;
+ }
+
+ return domRect;
+}
+
+/**
+ * Returns whether or not the flex data has changed.
+ *
+ * @param {Flex} oldFlexData
+ * The old Flex data object.
+ * @param {Flex} newFlexData
+ * The new Flex data object.
+ * @return {Boolean} true if the flex data has changed and false otherwise.
+ */
+// eslint-disable-next-line complexity
+function compareFlexData(oldFlexData, newFlexData) {
+ if (!oldFlexData || !newFlexData) {
+ return true;
+ }
+
+ const oldLines = oldFlexData.lines;
+ const newLines = newFlexData.lines;
+
+ if (oldLines.length !== newLines.length) {
+ return true;
+ }
+
+ for (let i = 0; i < oldLines.length; i++) {
+ const oldLine = oldLines[i];
+ const newLine = newLines[i];
+
+ if (
+ oldLine.crossSize !== newLine.crossSize ||
+ oldLine.crossStart !== newLine.crossStart ||
+ oldLine.firstBaselineOffset !== newLine.firstBaselineOffset ||
+ oldLine.growthState !== newLine.growthState ||
+ oldLine.lastBaselineOffset !== newLine.lastBaselineOffset
+ ) {
+ return true;
+ }
+
+ const oldItems = oldLine.items;
+ const newItems = newLine.items;
+
+ if (oldItems.length !== newItems.length) {
+ return true;
+ }
+
+ for (let j = 0; j < oldItems.length; j++) {
+ const oldItem = oldItems[j];
+ const newItem = newItems[j];
+
+ if (
+ oldItem.crossMaxSize !== newItem.crossMaxSize ||
+ oldItem.crossMinSize !== newItem.crossMinSize ||
+ oldItem.mainBaseSize !== newItem.mainBaseSize ||
+ oldItem.mainDeltaSize !== newItem.mainDeltaSize ||
+ oldItem.mainMaxSize !== newItem.mainMaxSize ||
+ oldItem.mainMinSize !== newItem.mainMinSize
+ ) {
+ return true;
+ }
+
+ const oldItemRect = oldItem.rect;
+ const newItemRect = newItem.rect;
+
+ // We are using DOMRects so we only need to compare x, y, width and
+ // height (left, top, right and bottom are calculated from these values).
+ if (
+ oldItemRect.x !== newItemRect.x ||
+ oldItemRect.y !== newItemRect.y ||
+ oldItemRect.width !== newItemRect.width ||
+ oldItemRect.height !== newItemRect.height
+ ) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+exports.FlexboxHighlighter = FlexboxHighlighter;
diff --git a/devtools/server/actors/highlighters/fonts.js b/devtools/server/actors/highlighters/fonts.js
new file mode 100644
index 0000000000..0fe6b066c7
--- /dev/null
+++ b/devtools/server/actors/highlighters/fonts.js
@@ -0,0 +1,121 @@
+/* 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,
+ "loadSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "removeSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+// How many text runs are we highlighting at a time. There may be many text runs, and we
+// want to prevent performance problems.
+const MAX_TEXT_RANGES = 100;
+
+// This stylesheet is inserted into the page to customize the color of the selected text
+// runs.
+// Note that this color is defined as --highlighter-content-color in the highlighters.css
+// file, and corresponds to the box-model content color. We want to give it an opacity of
+// 0.6 here.
+const STYLESHEET_URI =
+ "data:text/css," +
+ encodeURIComponent(
+ "::selection{background-color:hsl(197,71%,73%,.6)!important;}"
+ );
+
+/**
+ * This highlighter highlights runs of text in the page that have been rendered given a
+ * certain font. The highlighting is done with window selection ranges, so no extra
+ * markup is being inserted into the content page.
+ */
+class FontsHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ }
+
+ destroy() {
+ this.hide();
+ this.env = this.currentNode = null;
+ }
+
+ get currentNodeDocument() {
+ if (!this.currentNode) {
+ return this.env.document;
+ }
+
+ if (this.currentNode.nodeType === this.currentNode.DOCUMENT_NODE) {
+ return this.currentNode;
+ }
+
+ return this.currentNode.ownerDocument;
+ }
+
+ /**
+ * Show the highlighter for a given node.
+ * @param {DOMNode} node The node in which we want to search for text runs.
+ * @param {Object} options A bunch of options that can be set:
+ * - {String} name The actual font name to look for in the node.
+ * - {String} CSSFamilyName The CSS font-family name given to this font.
+ */
+ show(node, options) {
+ this.currentNode = node;
+ const doc = this.currentNodeDocument;
+
+ // Get all of the fonts used to render content inside the node.
+ const searchRange = doc.createRange();
+ searchRange.selectNodeContents(node);
+
+ const fonts = InspectorUtils.getUsedFontFaces(searchRange, MAX_TEXT_RANGES);
+
+ // Find the ones we want, based on the provided option.
+ const matchingFonts = fonts.filter(
+ f => f.CSSFamilyName === options.CSSFamilyName && f.name === options.name
+ );
+ if (!matchingFonts.length) {
+ return;
+ }
+
+ // Load the stylesheet that will customize the color of the highlighter (using a
+ // ::selection rule).
+ loadSheet(this.env.window, STYLESHEET_URI);
+
+ // Create a multi-selection in the page to highlight the text runs.
+ const selection = doc.defaultView.getSelection();
+ selection.removeAllRanges();
+
+ for (const matchingFont of matchingFonts) {
+ for (const range of matchingFont.ranges) {
+ selection.addRange(range);
+ }
+ }
+ }
+
+ hide() {
+ // No node was highlighted before, don't need to continue any further.
+ if (!this.currentNode) {
+ return;
+ }
+
+ try {
+ removeSheet(this.env.window, STYLESHEET_URI);
+ } catch (e) {
+ // Silently fail here as we might not have inserted the stylesheet at all.
+ }
+
+ // Simply remove all current ranges in the seletion.
+ const doc = this.currentNodeDocument;
+ const selection = doc.defaultView.getSelection();
+ selection.removeAllRanges();
+ }
+}
+
+exports.FontsHighlighter = FontsHighlighter;
diff --git a/devtools/server/actors/highlighters/geometry-editor.js b/devtools/server/actors/highlighters/geometry-editor.js
new file mode 100644
index 0000000000..ce8ec92bd5
--- /dev/null
+++ b/devtools/server/actors/highlighters/geometry-editor.js
@@ -0,0 +1,808 @@
+/* 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 {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ setIgnoreLayoutChanges,
+ getAdjustedQuads,
+ getCurrentZoom,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ getCSSStyleRules,
+} = require("resource://devtools/shared/inspector/css-logic.js");
+
+const GEOMETRY_LABEL_SIZE = 6;
+
+// List of all DOM Events subscribed directly to the document from the
+// Geometry Editor highlighter
+const DOM_EVENTS = ["mousemove", "mouseup", "pagehide"];
+
+const _dragging = Symbol("geometry/dragging");
+
+/**
+ * Element geometry properties helper that gives names of position and size
+ * properties.
+ */
+var GeoProp = {
+ SIDES: ["top", "right", "bottom", "left"],
+ SIZES: ["width", "height"],
+
+ allProps() {
+ return [...this.SIDES, ...this.SIZES];
+ },
+
+ isSide(name) {
+ return this.SIDES.includes(name);
+ },
+
+ isSize(name) {
+ return this.SIZES.includes(name);
+ },
+
+ containsSide(names) {
+ return names.some(name => this.SIDES.includes(name));
+ },
+
+ containsSize(names) {
+ return names.some(name => this.SIZES.includes(name));
+ },
+
+ isHorizontal(name) {
+ return name === "left" || name === "right" || name === "width";
+ },
+
+ isInverted(name) {
+ return name === "right" || name === "bottom";
+ },
+
+ mainAxisStart(name) {
+ return this.isHorizontal(name) ? "left" : "top";
+ },
+
+ crossAxisStart(name) {
+ return this.isHorizontal(name) ? "top" : "left";
+ },
+
+ mainAxisSize(name) {
+ return this.isHorizontal(name) ? "width" : "height";
+ },
+
+ crossAxisSize(name) {
+ return this.isHorizontal(name) ? "height" : "width";
+ },
+
+ axis(name) {
+ return this.isHorizontal(name) ? "x" : "y";
+ },
+
+ crossAxis(name) {
+ return this.isHorizontal(name) ? "y" : "x";
+ },
+};
+
+/**
+ * Get the provided node's offsetParent dimensions.
+ * Returns an object with the {parent, dimension} properties.
+ * Note that the returned parent will be null if the offsetParent is the
+ * default, non-positioned, body or html node.
+ *
+ * node.offsetParent returns the nearest positioned ancestor but if it is
+ * non-positioned itself, we just return null to let consumers know the node is
+ * actually positioned relative to the viewport.
+ *
+ * @return {Object}
+ */
+function getOffsetParent(node) {
+ const win = node.ownerGlobal;
+
+ let offsetParent = node.offsetParent;
+ if (offsetParent && getComputedStyle(offsetParent).position === "static") {
+ offsetParent = null;
+ }
+
+ let width, height;
+ if (!offsetParent) {
+ height = win.innerHeight;
+ width = win.innerWidth;
+ } else {
+ height = offsetParent.offsetHeight;
+ width = offsetParent.offsetWidth;
+ }
+
+ return {
+ element: offsetParent,
+ dimension: { width, height },
+ };
+}
+
+/**
+ * Get the list of geometry properties that are actually set on the provided
+ * node.
+ *
+ * @param {Node} node The node to analyze.
+ * @return {Map} A map indexed by property name and where the value is an
+ * object having the cssRule property.
+ */
+function getDefinedGeometryProperties(node) {
+ const props = new Map();
+ if (!node) {
+ return props;
+ }
+
+ // Get the list of css rules applying to the current node.
+ const cssRules = getCSSStyleRules(node);
+ for (let i = 0; i < cssRules.length; i++) {
+ const rule = cssRules[i];
+ for (const name of GeoProp.allProps()) {
+ const value = rule.style.getPropertyValue(name);
+ if (value && value !== "auto") {
+ // getCSSStyleRules returns rules ordered from least to most specific
+ // so just override any previous properties we have set.
+ props.set(name, {
+ cssRule: rule,
+ });
+ }
+ }
+ }
+
+ // Go through the inline styles last, only if the node supports inline style
+ // (e.g. pseudo elements don't have a style property)
+ if (node.style) {
+ for (const name of GeoProp.allProps()) {
+ const value = node.style.getPropertyValue(name);
+ if (value && value !== "auto") {
+ props.set(name, {
+ // There's no cssRule to store here, so store the node instead since
+ // node.style exists.
+ cssRule: node,
+ });
+ }
+ }
+ }
+
+ // Post-process the list for invalid properties. This is done after the fact
+ // because of cases like relative positioning with both top and bottom where
+ // only top will actually be used, but both exists in css rules and computed
+ // styles.
+ const { position } = getComputedStyle(node);
+ for (const [name] of props) {
+ // Top/left/bottom/right on static positioned elements have no effect.
+ if (position === "static" && GeoProp.SIDES.includes(name)) {
+ props.delete(name);
+ }
+
+ // Bottom/right on relative positioned elements are only used if top/left
+ // are not defined.
+ const hasRightAndLeft = name === "right" && props.has("left");
+ const hasBottomAndTop = name === "bottom" && props.has("top");
+ if (position === "relative" && (hasRightAndLeft || hasBottomAndTop)) {
+ props.delete(name);
+ }
+ }
+
+ return props;
+}
+exports.getDefinedGeometryProperties = getDefinedGeometryProperties;
+
+/**
+ * The GeometryEditor highlights an elements's top, left, bottom, right, width
+ * and height dimensions, when they are set.
+ *
+ * To determine if an element has a set size and position, the highlighter lists
+ * the CSS rules that apply to the element and checks for the top, left, bottom,
+ * right, width and height properties.
+ * The highlighter won't be shown if the element doesn't have any of these
+ * properties set, but will be shown when at least 1 property is defined.
+ *
+ * The highlighter displays lines and labels for each of the defined properties
+ * in and around the element (relative to the offset parent when one exists).
+ * The highlighter also highlights the element itself and its offset parent if
+ * there is one.
+ *
+ * Note that the class name contains the word Editor because the aim is for the
+ * handles to be draggable in content to make the geometry editable.
+ */
+class GeometryEditorHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this.ID_CLASS_PREFIX = "geometry-editor-";
+
+ // The list of element geometry properties that can be set.
+ this.definedProperties = new Map();
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.initialize();
+
+ const { pageListenerTarget } = this.highlighterEnv;
+
+ // Register the geometry editor instance to all events we're interested in.
+ DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
+
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+ }
+
+ async initialize() {
+ await this.markup.initialize();
+ // Register the mousedown event for each Geometry Editor's handler.
+ // Those events are automatically removed when the markup is destroyed.
+ const onMouseDown = this.handleEvent.bind(this);
+
+ for (const side of GeoProp.SIDES) {
+ this.getElement("handler-" + side).addEventListener(
+ "mousedown",
+ onMouseDown
+ );
+ }
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ width: "100%",
+ height: "100%",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Offset parent node highlighter.
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: svg,
+ attributes: {
+ class: "offset-parent",
+ id: "offset-parent",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Current node highlighter (margin box).
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: svg,
+ attributes: {
+ class: "current-node",
+ id: "current-node",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Build the 4 side arrows, handlers and labels.
+ for (const name of GeoProp.SIDES) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ class: "arrow " + name,
+ id: "arrow-" + name,
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "circle",
+ parent: svg,
+ attributes: {
+ class: "handler-" + name,
+ id: "handler-" + name,
+ r: "4",
+ "data-side": name,
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Labels are positioned by using a translated <g>. This group contains
+ // a path and text that are themselves positioned using another translated
+ // <g>. This is so that the label arrow points at the 0,0 coordinates of
+ // parent <g>.
+ const labelG = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: svg,
+ attributes: {
+ id: "label-" + name,
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const subG = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: labelG,
+ attributes: {
+ transform: GeoProp.isHorizontal(name)
+ ? "translate(-30 -30)"
+ : "translate(5 -10)",
+ },
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: subG,
+ attributes: {
+ class: "label-bubble",
+ d: GeoProp.isHorizontal(name)
+ ? "M0 0 L60 0 L60 20 L35 20 L30 25 L25 20 L0 20z"
+ : "M5 0 L65 0 L65 20 L5 20 L5 15 L0 10 L5 5z",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "text",
+ parent: subG,
+ attributes: {
+ class: "label-text",
+ id: "label-text-" + name,
+ x: GeoProp.isHorizontal(name) ? "30" : "35",
+ y: "10",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+ }
+
+ return container;
+ }
+
+ destroy() {
+ // Avoiding exceptions if `destroy` is called multiple times; and / or the
+ // highlighter environment was already destroyed.
+ if (!this.highlighterEnv) {
+ return;
+ }
+
+ const { pageListenerTarget } = this.highlighterEnv;
+
+ if (pageListenerTarget) {
+ DOM_EVENTS.forEach(type =>
+ pageListenerTarget.removeEventListener(type, this)
+ );
+ }
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+
+ this.markup.destroy();
+ this.definedProperties.clear();
+ this.definedProperties = null;
+ this.offsetParent = null;
+ }
+
+ handleEvent(event, id) {
+ // No event handling if the highlighter is hidden
+ if (this.getElement("root").hasAttribute("hidden")) {
+ return;
+ }
+
+ const { target, type, pageX, pageY } = event;
+
+ switch (type) {
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.destroy();
+ }
+
+ break;
+ case "mousedown":
+ // The mousedown event is intended only for the handler
+ if (!id) {
+ return;
+ }
+
+ const handlerSide = this.markup
+ .getElement(id)
+ .getAttribute("data-side");
+
+ if (handlerSide) {
+ const side = handlerSide;
+ const sideProp = this.definedProperties.get(side);
+
+ if (!sideProp) {
+ return;
+ }
+
+ let value = sideProp.cssRule.style.getPropertyValue(side);
+ const computedValue = this.computedStyle.getPropertyValue(side);
+
+ const [unit] = value.match(/[^\d]+$/) || [""];
+
+ value = parseFloat(value);
+
+ const ratio = value / parseFloat(computedValue) || 1;
+ const dir = GeoProp.isInverted(side) ? -1 : 1;
+
+ // Store all the initial values needed for drag & drop
+ this[_dragging] = {
+ side,
+ value,
+ unit,
+ x: pageX,
+ y: pageY,
+ inc: ratio * dir,
+ };
+
+ this.getElement("handler-" + side).classList.add("dragging");
+ }
+
+ this.getElement("root").setAttribute("dragging", "true");
+ break;
+ case "mouseup":
+ // If we're dragging, drop it.
+ if (this[_dragging]) {
+ const { side } = this[_dragging];
+ this.getElement("root").removeAttribute("dragging");
+ this.getElement("handler-" + side).classList.remove("dragging");
+ this[_dragging] = null;
+ }
+ break;
+ case "mousemove":
+ if (!this[_dragging]) {
+ return;
+ }
+
+ const { side, x, y, value, unit, inc } = this[_dragging];
+ const sideProps = this.definedProperties.get(side);
+
+ if (!sideProps) {
+ return;
+ }
+
+ const delta =
+ (GeoProp.isHorizontal(side) ? pageX - x : pageY - y) * inc;
+
+ // The inline style has usually the priority over any other CSS rule
+ // set in stylesheets. However, if a rule has `!important` keyword,
+ // it will override the inline style too. To ensure Geometry Editor
+ // will always update the element, we have to add `!important` as
+ // well.
+ this.currentNode.style.setProperty(
+ side,
+ value + delta + unit,
+ "important"
+ );
+
+ break;
+ }
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ _show() {
+ this.computedStyle = getComputedStyle(this.currentNode);
+ const pos = this.computedStyle.position;
+ // XXX: sticky positioning is ignored for now. To be implemented next.
+ if (pos === "sticky") {
+ this.hide();
+ return false;
+ }
+
+ const hasUpdated = this._update();
+ if (!hasUpdated) {
+ this.hide();
+ return false;
+ }
+
+ this.getElement("root").removeAttribute("hidden");
+
+ return true;
+ }
+
+ _update() {
+ // At each update, the position or/and size may have changed, so get the
+ // list of defined properties, and re-position the arrows and highlighters.
+ this.definedProperties = getDefinedGeometryProperties(this.currentNode);
+ // We need the zoom factor to fix the original position of the node
+ // as well as the arrows.
+ this.zoomFactor = getCurrentZoom(this.currentNode);
+
+ if (!this.definedProperties.size) {
+ console.warn("The element does not have editable geometry properties");
+ return false;
+ }
+
+ setIgnoreLayoutChanges(true);
+
+ // Update the highlighters and arrows.
+ this.updateOffsetParent();
+ this.updateCurrentNode();
+ this.updateArrows();
+
+ // Avoid zooming the arrows when content is zoomed.
+ const node = this.currentNode;
+ this.markup.scaleRootElement(node, this.ID_CLASS_PREFIX + "root");
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ return true;
+ }
+
+ /**
+ * Update the offset parent rectangle.
+ * There are 3 different cases covered here:
+ * - the node is absolutely/fixed positioned, and an offsetParent is defined
+ * (i.e. it's not just positioned in the viewport): the offsetParent node
+ * is highlighted (i.e. the rectangle is shown),
+ * - the node is relatively positioned: the rectangle is shown where the node
+ * would originally have been (because that's where the relative positioning
+ * is calculated from),
+ * - the node has no offset parent at all: the offsetParent rectangle is
+ * hidden.
+ */
+ updateOffsetParent() {
+ // Get the offsetParent, if any.
+ this.offsetParent = getOffsetParent(this.currentNode);
+ // And the offsetParent quads.
+ this.parentQuads = getAdjustedQuads(
+ this.win,
+ this.offsetParent.element,
+ "padding"
+ );
+
+ const el = this.getElement("offset-parent");
+
+ const isPositioned =
+ this.computedStyle.position === "absolute" ||
+ this.computedStyle.position === "fixed";
+ const isRelative = this.computedStyle.position === "relative";
+ let isHighlighted = false;
+
+ if (this.offsetParent.element && isPositioned) {
+ const { p1, p2, p3, p4 } = this.parentQuads[0];
+ const points =
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ p4.x +
+ "," +
+ p4.y;
+ el.setAttribute("points", points);
+ isHighlighted = true;
+ } else if (isRelative) {
+ const xDelta = parseFloat(this.computedStyle.left) * this.zoomFactor;
+ const yDelta = parseFloat(this.computedStyle.top) * this.zoomFactor;
+ if (xDelta || yDelta) {
+ const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
+ const points =
+ p1.x -
+ xDelta +
+ "," +
+ (p1.y - yDelta) +
+ " " +
+ (p2.x - xDelta) +
+ "," +
+ (p2.y - yDelta) +
+ " " +
+ (p3.x - xDelta) +
+ "," +
+ (p3.y - yDelta) +
+ " " +
+ (p4.x - xDelta) +
+ "," +
+ (p4.y - yDelta);
+ el.setAttribute("points", points);
+ isHighlighted = true;
+ }
+ }
+
+ if (isHighlighted) {
+ el.removeAttribute("hidden");
+ } else {
+ el.setAttribute("hidden", "true");
+ }
+ }
+
+ updateCurrentNode() {
+ const box = this.getElement("current-node");
+ const { p1, p2, p3, p4 } = this.currentQuads.margin[0];
+ const attr =
+ p1.x +
+ "," +
+ p1.y +
+ " " +
+ p2.x +
+ "," +
+ p2.y +
+ " " +
+ p3.x +
+ "," +
+ p3.y +
+ " " +
+ p4.x +
+ "," +
+ p4.y;
+ box.setAttribute("points", attr);
+ box.removeAttribute("hidden");
+ }
+
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("root").setAttribute("hidden", "true");
+ this.getElement("current-node").setAttribute("hidden", "true");
+ this.getElement("offset-parent").setAttribute("hidden", "true");
+ this.hideArrows();
+
+ this.definedProperties.clear();
+
+ setIgnoreLayoutChanges(false, this.highlighterEnv.document.documentElement);
+ }
+
+ hideArrows() {
+ for (const side of GeoProp.SIDES) {
+ this.getElement("arrow-" + side).setAttribute("hidden", "true");
+ this.getElement("label-" + side).setAttribute("hidden", "true");
+ this.getElement("handler-" + side).setAttribute("hidden", "true");
+ }
+ }
+
+ updateArrows() {
+ this.hideArrows();
+
+ // Position arrows always end at the node's margin box.
+ const marginBox = this.currentQuads.margin[0].bounds;
+
+ // Position the side arrows which need to be visible.
+ // Arrows always start at the offsetParent edge, and end at the middle
+ // position of the node's margin edge.
+ // Note that for relative positioning, the offsetParent is considered to be
+ // the node itself, where it would have been originally.
+ // +------------------+----------------+
+ // | offsetparent | top |
+ // | or viewport | |
+ // | +--------+--------+ |
+ // | | node | |
+ // +---------+ +-------+
+ // | left | | right |
+ // | +--------+--------+ |
+ // | | bottom |
+ // +------------------+----------------+
+ const getSideArrowStartPos = side => {
+ // In case of relative positioning.
+ if (this.computedStyle.position === "relative") {
+ if (GeoProp.isInverted(side)) {
+ return (
+ marginBox[side] +
+ parseFloat(this.computedStyle[side]) * this.zoomFactor
+ );
+ }
+ return (
+ marginBox[side] -
+ parseFloat(this.computedStyle[side]) * this.zoomFactor
+ );
+ }
+
+ // In case an offsetParent exists and is highlighted.
+ if (this.parentQuads && this.parentQuads.length) {
+ return this.parentQuads[0].bounds[side];
+ }
+
+ // In case the element is positioned in the viewport.
+ if (GeoProp.isInverted(side)) {
+ return this.offsetParent.dimension[GeoProp.mainAxisSize(side)];
+ }
+ return (
+ -1 *
+ this.currentNode.ownerGlobal[
+ "scroll" + GeoProp.axis(side).toUpperCase()
+ ]
+ );
+ };
+
+ for (const side of GeoProp.SIDES) {
+ const sideProp = this.definedProperties.get(side);
+ if (!sideProp) {
+ continue;
+ }
+
+ const mainAxisStartPos = getSideArrowStartPos(side);
+ const mainAxisEndPos = marginBox[side];
+ const crossAxisPos =
+ marginBox[GeoProp.crossAxisStart(side)] +
+ marginBox[GeoProp.crossAxisSize(side)] / 2;
+
+ this.updateArrow(
+ side,
+ mainAxisStartPos,
+ mainAxisEndPos,
+ crossAxisPos,
+ sideProp.cssRule.style.getPropertyValue(side)
+ );
+ }
+ }
+
+ updateArrow(side, mainStart, mainEnd, crossPos, labelValue) {
+ const arrowEl = this.getElement("arrow-" + side);
+ const labelEl = this.getElement("label-" + side);
+ const labelTextEl = this.getElement("label-text-" + side);
+ const handlerEl = this.getElement("handler-" + side);
+
+ // Position the arrow <line>.
+ arrowEl.setAttribute(GeoProp.axis(side) + "1", mainStart);
+ arrowEl.setAttribute(GeoProp.crossAxis(side) + "1", crossPos);
+ arrowEl.setAttribute(GeoProp.axis(side) + "2", mainEnd);
+ arrowEl.setAttribute(GeoProp.crossAxis(side) + "2", crossPos);
+ arrowEl.removeAttribute("hidden");
+
+ handlerEl.setAttribute("c" + GeoProp.axis(side), mainEnd);
+ handlerEl.setAttribute("c" + GeoProp.crossAxis(side), crossPos);
+ handlerEl.removeAttribute("hidden");
+
+ // Position the label <text> in the middle of the arrow (making sure it's
+ // not hidden below the fold).
+ const capitalize = str => str[0].toUpperCase() + str.substring(1);
+ const winMain = this.win["inner" + capitalize(GeoProp.mainAxisSize(side))];
+ let labelMain = mainStart + (mainEnd - mainStart) / 2;
+ if (
+ (mainStart > 0 && mainStart < winMain) ||
+ (mainEnd > 0 && mainEnd < winMain)
+ ) {
+ if (labelMain < GEOMETRY_LABEL_SIZE) {
+ labelMain = GEOMETRY_LABEL_SIZE;
+ } else if (labelMain > winMain - GEOMETRY_LABEL_SIZE) {
+ labelMain = winMain - GEOMETRY_LABEL_SIZE;
+ }
+ }
+ const labelCross = crossPos;
+ labelEl.setAttribute(
+ "transform",
+ GeoProp.isHorizontal(side)
+ ? "translate(" + labelMain + " " + labelCross + ")"
+ : "translate(" + labelCross + " " + labelMain + ")"
+ );
+ labelEl.removeAttribute("hidden");
+ labelTextEl.setTextContent(labelValue);
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.GeometryEditorHighlighter = GeometryEditorHighlighter;
diff --git a/devtools/server/actors/highlighters/measuring-tool.js b/devtools/server/actors/highlighters/measuring-tool.js
new file mode 100644
index 0000000000..41c3b3098f
--- /dev/null
+++ b/devtools/server/actors/highlighters/measuring-tool.js
@@ -0,0 +1,853 @@
+/* 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 {
+ getCurrentZoom,
+ getWindowDimensions,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// Hard coded value about the size of measuring tool label, in order to
+// position and flip it when is needed.
+const LABEL_SIZE_MARGIN = 8;
+const LABEL_SIZE_WIDTH = 80;
+const LABEL_SIZE_HEIGHT = 52;
+const LABEL_POS_MARGIN = 4;
+const LABEL_POS_WIDTH = 40;
+const LABEL_POS_HEIGHT = 34;
+const LABEL_TYPE_SIZE = "size";
+const LABEL_TYPE_POSITION = "position";
+
+// List of all DOM Events subscribed directly to the document from the
+// Measuring Tool highlighter
+const DOM_EVENTS = [
+ "mousedown",
+ "mousemove",
+ "mouseup",
+ "mouseleave",
+ "scroll",
+ "pagehide",
+ "keydown",
+ "keyup",
+];
+
+const SIDES = ["top", "right", "bottom", "left"];
+const HANDLERS = [...SIDES, "topleft", "topright", "bottomleft", "bottomright"];
+const HANDLER_SIZE = 6;
+const HIGHLIGHTED_HANDLER_CLASSNAME = "highlight";
+
+const IS_OSX = Services.appinfo.OS === "Darwin";
+
+/**
+ * The MeasuringToolHighlighter is used to measure distances in a content page.
+ * It allows users to click and drag with their mouse to draw an area whose
+ * dimensions will be displayed in a tooltip next to it.
+ * This allows users to measure distances between elements on a page.
+ */
+class MeasuringToolHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ this.rect = { x: 0, y: 0, w: 0, h: 0 };
+ this.mouseCoords = { x: 0, y: 0 };
+
+ const { pageListenerTarget } = highlighterEnv;
+
+ // Register the measuring tool instance to all events we're interested in.
+ DOM_EVENTS.forEach(type => pageListenerTarget.addEventListener(type, this));
+ }
+
+ ID_CLASS_PREFIX = "measuring-tool-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ },
+ prefix,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ class: "elements",
+ width: "100%",
+ height: "100%",
+ },
+ prefix,
+ });
+
+ for (const side of SIDES) {
+ this.markup.createSVGNode({
+ nodeType: "line",
+ parent: svg,
+ attributes: {
+ class: `guide-${side}`,
+ id: `guide-${side}`,
+ hidden: "true",
+ },
+ prefix,
+ });
+ }
+
+ this.markup.createNode({
+ nodeType: "label",
+ attributes: {
+ id: "label-size",
+ class: "label-size",
+ hidden: "true",
+ },
+ parent: root,
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "label",
+ attributes: {
+ id: "label-position",
+ class: "label-position",
+ hidden: "true",
+ },
+ parent: root,
+ prefix,
+ });
+
+ // Creating a <g> element in order to group all the paths below, that
+ // together represent the measuring tool; so that would be easier move them
+ // around
+ const g = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: "tool",
+ },
+ parent: svg,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ id: "box-path",
+ class: "box-path",
+ },
+ parent: g,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ id: "diagonal-path",
+ class: "diagonal-path",
+ },
+ parent: g,
+ prefix,
+ });
+
+ for (const handler of HANDLERS) {
+ this.markup.createSVGNode({
+ nodeType: "circle",
+ parent: g,
+ attributes: {
+ class: `handler-${handler}`,
+ id: `handler-${handler}`,
+ r: HANDLER_SIZE,
+ hidden: "true",
+ },
+ prefix,
+ });
+ }
+
+ return container;
+ }
+
+ _update() {
+ const { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ const zoom = getCurrentZoom(window);
+
+ const { width, height } = getWindowDimensions(window);
+
+ const { rect } = this;
+
+ const isZoomChanged = zoom !== rect.zoom;
+
+ if (isZoomChanged) {
+ rect.zoom = zoom;
+ this.updateLabel();
+ }
+
+ const isDocumentSizeChanged =
+ width !== rect.documentWidth || height !== rect.documentHeight;
+
+ if (isDocumentSizeChanged) {
+ rect.documentWidth = width;
+ rect.documentHeight = height;
+ }
+
+ // If either the document's size or the zoom is changed since the last
+ // repaint, we update the tool's size as well.
+ if (isZoomChanged || isDocumentSizeChanged) {
+ this.updateViewport();
+ }
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ }
+
+ _cancelUpdate() {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ }
+
+ destroy() {
+ this.hide();
+
+ this._cancelUpdate();
+
+ const { pageListenerTarget } = this.env;
+
+ if (pageListenerTarget) {
+ DOM_EVENTS.forEach(type =>
+ pageListenerTarget.removeEventListener(type, this)
+ );
+ }
+
+ this.markup.destroy();
+
+ EventEmitter.emit(this, "destroy");
+ }
+
+ show() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("root").removeAttribute("hidden");
+
+ this._update();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ hide() {
+ setIgnoreLayoutChanges(true);
+
+ this.hideLabel(LABEL_TYPE_SIZE);
+ this.hideLabel(LABEL_TYPE_POSITION);
+
+ this.getElement("root").setAttribute("hidden", "true");
+
+ this._cancelUpdate();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ setSize(w, h) {
+ this.setRect(undefined, undefined, w, h);
+ }
+
+ setRect(x, y, w, h) {
+ const { rect } = this;
+
+ if (typeof x !== "undefined") {
+ rect.x = x;
+ }
+
+ if (typeof y !== "undefined") {
+ rect.y = y;
+ }
+
+ if (typeof w !== "undefined") {
+ rect.w = w;
+ }
+
+ if (typeof h !== "undefined") {
+ rect.h = h;
+ }
+
+ setIgnoreLayoutChanges(true);
+
+ if (this._dragging) {
+ this.updatePaths();
+ this.updateHandlers();
+ }
+
+ this.updateLabel();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ updatePaths() {
+ const { x, y, w, h } = this.rect;
+ const dir = `M0 0 L${w} 0 L${w} ${h} L0 ${h}z`;
+
+ // Adding correction to the line path, otherwise some pixels are drawn
+ // outside the main rectangle area.
+ const x1 = w > 0 ? 0.5 : 0;
+ const y1 = w < 0 && h < 0 ? -0.5 : 0;
+ const w1 = w + (h < 0 && w < 0 ? 0.5 : 0);
+ const h1 = h + (h > 0 && w > 0 ? -0.5 : 0);
+
+ const linedir = `M${x1} ${y1} L${w1} ${h1}`;
+
+ this.getElement("box-path").setAttribute("d", dir);
+ this.getElement("diagonal-path").setAttribute("d", linedir);
+ this.getElement("tool").setAttribute("transform", `translate(${x},${y})`);
+ }
+
+ updateLabel(type) {
+ type = type || (this._dragging ? LABEL_TYPE_SIZE : LABEL_TYPE_POSITION);
+
+ const isSizeLabel = type === LABEL_TYPE_SIZE;
+
+ const label = this.getElement(`label-${type}`);
+
+ let origin = "top left";
+
+ const { innerWidth, innerHeight, scrollX, scrollY } = this.env.window;
+ const { x: mouseX, y: mouseY } = this.mouseCoords;
+ let { x, y, w, h, zoom } = this.rect;
+ const scale = 1 / zoom;
+
+ w = w || 0;
+ h = h || 0;
+ x = x || 0;
+ y = y || 0;
+ if (type === LABEL_TYPE_SIZE) {
+ x += w;
+ y += h;
+ } else {
+ x = mouseX;
+ y = mouseY;
+ }
+
+ let labelMargin, labelHeight, labelWidth;
+
+ if (isSizeLabel) {
+ labelMargin = LABEL_SIZE_MARGIN;
+ labelWidth = LABEL_SIZE_WIDTH;
+ labelHeight = LABEL_SIZE_HEIGHT;
+
+ const d = Math.hypot(w, h).toFixed(2);
+
+ label.setTextContent(`W: ${Math.abs(w)} px
+ H: ${Math.abs(h)} px
+ ↘: ${d}px`);
+ } else {
+ labelMargin = LABEL_POS_MARGIN;
+ labelWidth = LABEL_POS_WIDTH;
+ labelHeight = LABEL_POS_HEIGHT;
+
+ label.setTextContent(`${mouseX}
+ ${mouseY}`);
+ }
+
+ // Size used to position properly the label
+ const labelBoxWidth = (labelWidth + labelMargin) * scale;
+ const labelBoxHeight = (labelHeight + labelMargin) * scale;
+
+ const isGoingLeft = w < scrollX;
+ const isSizeGoingLeft = isSizeLabel && isGoingLeft;
+ const isExceedingLeftMargin = x - labelBoxWidth < scrollX;
+ const isExceedingRightMargin = x + labelBoxWidth > innerWidth + scrollX;
+ const isExceedingTopMargin = y - labelBoxHeight < scrollY;
+ const isExceedingBottomMargin = y + labelBoxHeight > innerHeight + scrollY;
+
+ if ((isSizeGoingLeft && !isExceedingLeftMargin) || isExceedingRightMargin) {
+ x -= labelBoxWidth;
+ origin = "top right";
+ } else {
+ x += labelMargin * scale;
+ }
+
+ if (isSizeLabel) {
+ y += isExceedingTopMargin ? labelMargin * scale : -labelBoxHeight;
+ } else {
+ y += isExceedingBottomMargin ? -labelBoxHeight : labelMargin * scale;
+ }
+
+ label.setAttribute(
+ "style",
+ `
+ width: ${labelWidth}px;
+ height: ${labelHeight}px;
+ transform-origin: ${origin};
+ transform: translate(${x}px,${y}px) scale(${scale})
+ `
+ );
+
+ if (!isSizeLabel) {
+ const labelSize = this.getElement("label-size");
+ const style = labelSize.getAttribute("style");
+
+ if (style) {
+ labelSize.setAttribute(
+ "style",
+ style.replace(/scale[^)]+\)/, `scale(${scale})`)
+ );
+ }
+ }
+ }
+
+ updateViewport() {
+ const { devicePixelRatio } = this.env.window;
+ const { documentWidth, documentHeight, zoom } = this.rect;
+
+ // Because `devicePixelRatio` is affected by zoom (see bug 809788),
+ // in order to get the "real" device pixel ratio, we need divide by `zoom`
+ const pixelRatio = devicePixelRatio / zoom;
+
+ // The "real" device pixel ratio is used to calculate the max stroke
+ // width we can actually assign: on retina, for instance, it would be 0.5,
+ // where on non high dpi monitor would be 1.
+ const minWidth = 1 / pixelRatio;
+ const strokeWidth = minWidth / zoom;
+
+ this.getElement("root").setAttribute(
+ "style",
+ `stroke-width:${strokeWidth};
+ width:${documentWidth}px;
+ height:${documentHeight}px;`
+ );
+ }
+
+ updateGuides() {
+ const { x, y, w, h } = this.rect;
+
+ let guide = this.getElement("guide-top");
+
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", y);
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", y);
+
+ guide = this.getElement("guide-right");
+
+ guide.setAttribute("x1", x + w);
+ guide.setAttribute("y1", 0);
+ guide.setAttribute("x2", x + w);
+ guide.setAttribute("y2", "100%");
+
+ guide = this.getElement("guide-bottom");
+
+ guide.setAttribute("x1", "0");
+ guide.setAttribute("y1", y + h);
+ guide.setAttribute("x2", "100%");
+ guide.setAttribute("y2", y + h);
+
+ guide = this.getElement("guide-left");
+
+ guide.setAttribute("x1", x);
+ guide.setAttribute("y1", 0);
+ guide.setAttribute("x2", x);
+ guide.setAttribute("y2", "100%");
+ }
+
+ setHandlerPosition(handler, x, y) {
+ const handlerElement = this.getElement(`handler-${handler}`);
+ handlerElement.setAttribute("cx", x);
+ handlerElement.setAttribute("cy", y);
+ }
+
+ updateHandlers() {
+ const { w, h } = this.rect;
+
+ this.setHandlerPosition("top", w / 2, 0);
+ this.setHandlerPosition("topright", w, 0);
+ this.setHandlerPosition("right", w, h / 2);
+ this.setHandlerPosition("bottomright", w, h);
+ this.setHandlerPosition("bottom", w / 2, h);
+ this.setHandlerPosition("bottomleft", 0, h);
+ this.setHandlerPosition("left", 0, h / 2);
+ this.setHandlerPosition("topleft", 0, 0);
+ }
+
+ showLabel(type) {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`label-${type}`).removeAttribute("hidden");
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ hideLabel(type) {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`label-${type}`).setAttribute("hidden", "true");
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ }
+
+ showGuides() {
+ const prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (const side of SIDES) {
+ this.markup.removeAttributeForElement(`${prefix + side}`, "hidden");
+ }
+ }
+
+ hideGuides() {
+ const prefix = this.ID_CLASS_PREFIX + "guide-";
+
+ for (const side of SIDES) {
+ this.markup.setAttributeForElement(`${prefix + side}`, "hidden", "true");
+ }
+ }
+
+ showHandler(id) {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+ this.markup.removeAttributeForElement(prefix + id, "hidden");
+ }
+
+ showHandlers() {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+
+ for (const handler of HANDLERS) {
+ this.markup.removeAttributeForElement(prefix + handler, "hidden");
+ }
+ }
+
+ hideAll() {
+ this.hideLabel(LABEL_TYPE_POSITION);
+ this.hideLabel(LABEL_TYPE_SIZE);
+ this.hideGuides();
+ this.hideHandlers();
+ }
+
+ showGuidesAndHandlers() {
+ // Shows the guides and handlers only if an actual area is selected
+ if (this.rect.w !== 0 && this.rect.h !== 0) {
+ this.updateGuides();
+ this.showGuides();
+ this.updateHandlers();
+ this.showHandlers();
+ }
+ }
+
+ hideHandlers() {
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+
+ for (const handler of HANDLERS) {
+ this.markup.setAttributeForElement(prefix + handler, "hidden", "true");
+ }
+ }
+
+ handleEvent(event) {
+ const { target, type } = event;
+
+ switch (type) {
+ case "mousedown":
+ if (event.button || this._dragging) {
+ return;
+ }
+
+ const isHandler = event.originalTarget.id.includes("handler");
+ if (isHandler) {
+ this.handleResizingMouseDownEvent(event);
+ } else {
+ this.handleMouseDownEvent(event);
+ }
+ break;
+ case "mousemove":
+ if (this._dragging && this._dragging.handler) {
+ this.handleResizingMouseMoveEvent(event);
+ } else {
+ this.handleMouseMoveEvent(event);
+ }
+ break;
+ case "mouseup":
+ if (this._dragging) {
+ if (this._dragging.handler) {
+ this.handleResizingMouseUpEvent();
+ } else {
+ this.handleMouseUpEvent();
+ }
+ }
+ break;
+ case "mouseleave": {
+ if (!this._dragging) {
+ this.hideLabel(LABEL_TYPE_POSITION);
+ }
+ break;
+ }
+ case "scroll": {
+ this.hideLabel(LABEL_TYPE_POSITION);
+ break;
+ }
+ case "pagehide": {
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.env.window) {
+ this.destroy();
+ }
+ break;
+ }
+ case "keydown": {
+ this.handleKeyDown(event);
+ break;
+ }
+ case "keyup": {
+ if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
+ this.getElement("handler-topleft").classList.remove(
+ HIGHLIGHTED_HANDLER_CLASSNAME
+ );
+ }
+ break;
+ }
+ }
+ }
+
+ handleMouseDownEvent(event) {
+ const { pageX, pageY } = event;
+ const { window } = this.env;
+ const elementId = `${this.ID_CLASS_PREFIX}tool`;
+
+ setIgnoreLayoutChanges(true);
+
+ this.markup.getElement(elementId).classList.add("dragging");
+
+ this.hideAll();
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ // Store all the initial values needed for drag & drop
+ this._dragging = {
+ handler: null,
+ x: pageX,
+ y: pageY,
+ };
+
+ this.setRect(pageX, pageY, 0, 0);
+ }
+
+ handleMouseMoveEvent(event) {
+ const { pageX, pageY } = event;
+ const { mouseCoords } = this;
+ let { x, y, w, h } = this.rect;
+ let labelType;
+
+ if (this._dragging) {
+ w = pageX - x;
+ h = pageY - y;
+
+ this.setRect(x, y, w, h);
+
+ labelType = LABEL_TYPE_SIZE;
+ } else {
+ mouseCoords.x = pageX;
+ mouseCoords.y = pageY;
+ this.updateLabel(LABEL_TYPE_POSITION);
+
+ labelType = LABEL_TYPE_POSITION;
+ }
+
+ this.showLabel(labelType);
+ }
+
+ handleMouseUpEvent() {
+ setIgnoreLayoutChanges(true);
+
+ this.getElement("tool").classList.remove("dragging");
+
+ this.showGuidesAndHandlers();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ this._dragging = null;
+ }
+
+ handleResizingMouseDownEvent(event) {
+ const { originalTarget, pageX, pageY } = event;
+ const { window } = this.env;
+ const prefix = this.ID_CLASS_PREFIX + "handler-";
+ const handler = originalTarget.id.replace(prefix, "");
+
+ setIgnoreLayoutChanges(true);
+
+ this.markup.getElement(originalTarget.id).classList.add("dragging");
+
+ this.hideAll();
+ this.showHandler(handler);
+
+ // Set coordinates to the current measurement area's position
+ const [, x, y] = this.getElement("tool")
+ .getAttribute("transform")
+ .match(/(\d+),(\d+)/);
+ this.setRect(Number(x), Number(y));
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ // Store all the initial values needed for drag & drop
+ this._dragging = {
+ handler,
+ x: pageX,
+ y: pageY,
+ };
+ }
+
+ handleResizingMouseMoveEvent(event) {
+ const { pageX, pageY } = event;
+ const { rect } = this;
+ let { x, y, w, h } = rect;
+
+ const { handler } = this._dragging;
+
+ switch (handler) {
+ case "top":
+ y = pageY;
+ h = rect.y + rect.h - pageY;
+ break;
+ case "topright":
+ y = pageY;
+ w = pageX - rect.x;
+ h = rect.y + rect.h - pageY;
+ break;
+ case "right":
+ w = pageX - rect.x;
+ break;
+ case "bottomright":
+ w = pageX - rect.x;
+ h = pageY - rect.y;
+ break;
+ case "bottom":
+ h = pageY - rect.y;
+ break;
+ case "bottomleft":
+ x = pageX;
+ w = rect.x + rect.w - pageX;
+ h = pageY - rect.y;
+ break;
+ case "left":
+ x = pageX;
+ w = rect.x + rect.w - pageX;
+ break;
+ case "topleft":
+ x = pageX;
+ y = pageY;
+ w = rect.x + rect.w - pageX;
+ h = rect.y + rect.h - pageY;
+ break;
+ }
+
+ this.setRect(x, y, w, h);
+
+ // Changes the resizing cursors in case the measuring box is mirrored
+ const isMirrored =
+ (rect.w < 0 || rect.h < 0) && !(rect.w < 0 && rect.h < 0);
+ this.getElement("tool").classList.toggle("mirrored", isMirrored);
+
+ this.showLabel("size");
+ }
+
+ handleResizingMouseUpEvent() {
+ const { handler } = this._dragging;
+
+ setIgnoreLayoutChanges(true);
+
+ this.getElement(`handler-${handler}`).classList.remove("dragging");
+ this.showHandlers();
+
+ this.showGuidesAndHandlers();
+
+ setIgnoreLayoutChanges(false, this.env.window.document.documentElement);
+ this._dragging = null;
+ }
+
+ handleKeyDown(event) {
+ if (MeasuringToolHighlighter.#isResizeModifierPressed(event)) {
+ this.getElement("handler-topleft").classList.add(
+ HIGHLIGHTED_HANDLER_CLASSNAME
+ );
+ }
+
+ if (
+ !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)
+ ) {
+ return;
+ }
+
+ const { x, y, w, h } = this.rect;
+ const modifier = event.shiftKey ? 10 : 1;
+
+ event.preventDefault();
+ if (MeasuringToolHighlighter.#isResizeModifierHeld(event)) {
+ // If Ctrl (or Command on OS X) is held, resize the tool
+ switch (event.key) {
+ case "ArrowUp":
+ this.setSize(undefined, h - modifier);
+ break;
+ case "ArrowDown":
+ this.setSize(undefined, h + modifier);
+ break;
+ case "ArrowLeft":
+ this.setSize(w - modifier, undefined);
+ break;
+ case "ArrowRight":
+ this.setSize(w + modifier, undefined);
+ break;
+ }
+ } else {
+ // Arrow keys with no modifier move the tool
+ switch (event.key) {
+ case "ArrowUp":
+ this.setRect(undefined, y - modifier);
+ break;
+ case "ArrowDown":
+ this.setRect(undefined, y + modifier);
+ break;
+ case "ArrowLeft":
+ this.setRect(x - modifier, undefined);
+ break;
+ case "ArrowRight":
+ this.setRect(x + modifier, undefined);
+ break;
+ }
+ }
+
+ this.updatePaths();
+ this.updateGuides();
+ this.updateHandlers();
+ this.updateLabel(LABEL_TYPE_SIZE);
+ }
+
+ static #isResizeModifierPressed(event) {
+ return (
+ (!IS_OSX && event.key === "Control") || (IS_OSX && event.key === "Meta")
+ );
+ }
+
+ static #isResizeModifierHeld(event) {
+ return (!IS_OSX && event.ctrlKey) || (IS_OSX && event.metaKey);
+ }
+}
+exports.MeasuringToolHighlighter = MeasuringToolHighlighter;
diff --git a/devtools/server/actors/highlighters/moz.build b/devtools/server/actors/highlighters/moz.build
new file mode 100644
index 0000000000..eeaca89992
--- /dev/null
+++ b/devtools/server/actors/highlighters/moz.build
@@ -0,0 +1,31 @@
+# -*- 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 += [
+ "utils",
+ "css",
+]
+
+DevToolsModules(
+ "accessible.js",
+ "auto-refresh.js",
+ "box-model.js",
+ "css-grid.js",
+ "css-transform.js",
+ "eye-dropper.js",
+ "flexbox.js",
+ "fonts.js",
+ "geometry-editor.js",
+ "measuring-tool.js",
+ "node-tabbing-order.js",
+ "paused-debugger.js",
+ "remote-node-picker-notice.js",
+ "rulers.js",
+ "selector.js",
+ "shapes.js",
+ "tabbing-order.js",
+ "viewport-size.js",
+)
diff --git a/devtools/server/actors/highlighters/node-tabbing-order.js b/devtools/server/actors/highlighters/node-tabbing-order.js
new file mode 100644
index 0000000000..229342ee98
--- /dev/null
+++ b/devtools/server/actors/highlighters/node-tabbing-order.js
@@ -0,0 +1,399 @@
+/* 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,
+ ["setIgnoreLayoutChanges", "getCurrentZoom"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "AutoRefreshHighlighter",
+ "resource://devtools/server/actors/highlighters/auto-refresh.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CanvasFrameAnonymousContentHelper"],
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+
+/**
+ * The NodeTabbingOrderHighlighter draws an outline around a node (based on its
+ * border bounds).
+ *
+ * Usage example:
+ *
+ * const h = new NodeTabbingOrderHighlighter(env);
+ * await h.isReady();
+ * h.show(node, options);
+ * h.hide();
+ * h.destroy();
+ *
+ * @param {Number} options.index
+ * Tabbing index value to be displayed in the highlighter info bar.
+ */
+class NodeTabbingOrderHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+
+ this._doNotStartRefreshLoop = true;
+ this.ID_CLASS_PREFIX = "tabbing-order-";
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ _buildMarkup() {
+ const root = this.markup.createNode({
+ attributes: {
+ id: "root",
+ class: "root highlighter-container tabbing-order",
+ "aria-hidden": "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const container = this.markup.createNode({
+ parent: root,
+ attributes: {
+ id: "container",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Building the SVG element
+ this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "bounds",
+ id: "bounds",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Building the nodeinfo bar markup
+
+ const infobarContainer = this.markup.createNode({
+ parent: root,
+ attributes: {
+ class: "infobar-container",
+ id: "infobar-container",
+ position: "top",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const infobar = this.markup.createNode({
+ parent: infobarContainer,
+ attributes: {
+ class: "infobar",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createNode({
+ parent: infobar,
+ attributes: {
+ class: "infobar-text",
+ id: "infobar-text",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return root;
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ this.markup.destroy();
+
+ AutoRefreshHighlighter.prototype.destroy.call(this);
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Update focused styling for a node tabbing index highlight.
+ *
+ * @param {Boolean} focused
+ * Indicates if the highlighted node needs to be focused.
+ */
+ updateFocus(focused) {
+ const root = this.getElement("root");
+ root.classList.toggle("focused", focused);
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ return this._update();
+ }
+
+ /**
+ * Update the highlighter on the current highlighted node (the one that was
+ * passed as an argument to show(node)).
+ * Should be called whenever node size or attributes change
+ */
+ _update() {
+ let shown = false;
+ setIgnoreLayoutChanges(true);
+
+ if (this._updateTabbingOrder()) {
+ this._showInfobar();
+ this._showTabbingOrder();
+ shown = true;
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ } else {
+ // Nothing to highlight (0px rectangle like a <script> tag for instance)
+ this._hide();
+ }
+
+ return shown;
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this._hideTabbingOrder();
+ this._hideInfobar();
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ /**
+ * Hide the infobar
+ */
+ _hideInfobar() {
+ this.getElement("infobar-container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the infobar
+ */
+ _showInfobar() {
+ if (!this.currentNode) {
+ return;
+ }
+
+ this.getElement("infobar-container").removeAttribute("hidden");
+ this.getElement("infobar-text").setTextContent(this.options.index);
+ const bounds = this._getBounds();
+ const container = this.getElement("infobar-container");
+
+ moveInfobar(container, bounds, this.win);
+ }
+
+ /**
+ * Hide the tabbing order highlighter
+ */
+ _hideTabbingOrder() {
+ this.getElement("container").setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the tabbing order highlighter
+ */
+ _showTabbingOrder() {
+ this.getElement("container").removeAttribute("hidden");
+ }
+
+ /**
+ * Calculate border bounds based on the quads returned by getAdjustedQuads.
+ * @return {Object} A bounds object {bottom,height,left,right,top,width,x,y}
+ */
+ _getBorderBounds() {
+ const quads = this.currentQuads.border;
+ if (!quads || !quads.length) {
+ return null;
+ }
+
+ const bounds = {
+ bottom: -Infinity,
+ height: 0,
+ left: Infinity,
+ right: -Infinity,
+ top: Infinity,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+
+ for (const q of quads) {
+ bounds.bottom = Math.max(bounds.bottom, q.bounds.bottom);
+ bounds.top = Math.min(bounds.top, q.bounds.top);
+ bounds.left = Math.min(bounds.left, q.bounds.left);
+ bounds.right = Math.max(bounds.right, q.bounds.right);
+ }
+ bounds.x = bounds.left;
+ bounds.y = bounds.top;
+ bounds.width = bounds.right - bounds.left;
+ bounds.height = bounds.bottom - bounds.top;
+
+ return bounds;
+ }
+
+ /**
+ * Update the tabbing order index as per the current node.
+ *
+ * @return {boolean}
+ * True if the current node has a tabbing order index to be
+ * highlighted
+ */
+ _updateTabbingOrder() {
+ if (!this._nodeNeedsHighlighting()) {
+ this._hideTabbingOrder();
+ return false;
+ }
+
+ const boundsEl = this.getElement("bounds");
+ const { left, top, width, height } = this._getBounds();
+ boundsEl.setAttribute(
+ "style",
+ `top: ${top}px; left: ${left}px; width: ${width}px; height: ${height}px;`
+ );
+
+ // Un-zoom the root wrapper if the page was zoomed.
+ const rootId = this.ID_CLASS_PREFIX + "container";
+ this.markup.scaleRootElement(this.currentNode, rootId);
+
+ return true;
+ }
+
+ /**
+ * Can the current node be highlighted? Does it have quads.
+ * @return {Boolean}
+ */
+ _nodeNeedsHighlighting() {
+ return (
+ this.currentQuads.margin.length ||
+ this.currentQuads.border.length ||
+ this.currentQuads.padding.length ||
+ this.currentQuads.content.length
+ );
+ }
+
+ _getBounds() {
+ const borderBounds = this._getBorderBounds();
+ let bounds = {
+ bottom: 0,
+ height: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ width: 0,
+ x: 0,
+ y: 0,
+ };
+
+ if (!borderBounds) {
+ // Invisible element such as a script tag.
+ return bounds;
+ }
+
+ const { bottom, height, left, right, top, width, x, y } = borderBounds;
+ if (width > 0 || height > 0) {
+ bounds = { bottom, height, left, right, top, width, x, y };
+ }
+
+ return bounds;
+ }
+}
+
+/**
+ * Move the infobar to the right place in the highlighter. The infobar is used
+ * to display element's tabbing order index.
+ *
+ * @param {DOMNode} container
+ * The container element which will be used to position the infobar.
+ * @param {Object} bounds
+ * The content bounds of the container element.
+ * @param {Window} win
+ * The window object.
+ */
+function moveInfobar(container, bounds, win) {
+ const zoom = getCurrentZoom(win);
+ const { computedStyle } = container;
+ const margin = 2;
+ const arrowSize =
+ parseFloat(
+ computedStyle.getPropertyValue("--highlighter-bubble-arrow-size")
+ ) - 2;
+ const containerHeight = parseFloat(computedStyle.getPropertyValue("height"));
+ const containerWidth = parseFloat(computedStyle.getPropertyValue("width"));
+
+ const topBoundary = margin;
+ const bottomBoundary =
+ win.document.scrollingElement.scrollHeight - containerHeight - margin - 1;
+ const leftBoundary = containerWidth / 2 + margin;
+
+ let top = bounds.y - containerHeight - arrowSize;
+ let left = bounds.x + bounds.width / 2;
+ const bottom = bounds.bottom + arrowSize;
+ let positionAttribute = "top";
+
+ const canBePlacedOnTop = top >= topBoundary;
+ const canBePlacedOnBottom = bottomBoundary - bottom > 0;
+
+ if (!canBePlacedOnTop && canBePlacedOnBottom) {
+ top = bottom;
+ positionAttribute = "bottom";
+ }
+
+ let hideArrow = false;
+ if (top < topBoundary) {
+ hideArrow = true;
+ top = topBoundary;
+ } else if (top > bottomBoundary) {
+ hideArrow = true;
+ top = bottomBoundary;
+ }
+
+ if (left < leftBoundary) {
+ hideArrow = true;
+ left = leftBoundary;
+ }
+
+ if (hideArrow) {
+ container.setAttribute("hide-arrow", "true");
+ } else {
+ container.removeAttribute("hide-arrow");
+ }
+
+ container.setAttribute(
+ "style",
+ `
+ position: absolute;
+ transform-origin: 0 0;
+ transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)`
+ );
+
+ container.setAttribute("position", positionAttribute);
+}
+
+exports.NodeTabbingOrderHighlighter = NodeTabbingOrderHighlighter;
diff --git a/devtools/server/actors/highlighters/paused-debugger.js b/devtools/server/actors/highlighters/paused-debugger.js
new file mode 100644
index 0000000000..5035ab04c2
--- /dev/null
+++ b/devtools/server/actors/highlighters/paused-debugger.js
@@ -0,0 +1,260 @@
+/* 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 {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+loader.lazyGetter(this, "PausedReasonsBundle", () => {
+ return new Localization(
+ ["devtools/shared/debugger-paused-reasons.ftl"],
+ true
+ );
+});
+
+loader.lazyRequireGetter(
+ this,
+ "DEBUGGER_PAUSED_REASONS_L10N_MAPPING",
+ "resource://devtools/shared/constants.js",
+ true
+);
+
+/**
+ * The PausedDebuggerOverlay is a class that displays a semi-transparent mask on top of
+ * the whole page and a toolbar at the top of the page.
+ * This is used to signal to users that script execution is current paused.
+ * The toolbar is used to display the reason for the pause in script execution as well as
+ * buttons to resume or step through the program.
+ */
+class PausedDebuggerOverlay {
+ constructor(highlighterEnv, options = {}) {
+ this.env = highlighterEnv;
+ this.resume = options.resume;
+ this.stepOver = options.stepOver;
+
+ this.lastTarget = null;
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this),
+ { waitForDocumentToLoad: false }
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ ID_CLASS_PREFIX = "paused-dbg-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ // Wrapper element.
+ const wrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ hidden: "true",
+ overlay: "true",
+ },
+ prefix,
+ });
+
+ const toolbar = this.markup.createNode({
+ parent: wrapper,
+ attributes: {
+ id: "toolbar",
+ class: "toolbar",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: toolbar,
+ attributes: {
+ id: "reason",
+ class: "reason",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "divider",
+ class: "divider",
+ },
+ prefix,
+ });
+
+ const stepWrapper = this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "step-button-wrapper",
+ class: "step-button-wrapper",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "button",
+ parent: stepWrapper,
+ attributes: {
+ id: "step-button",
+ class: "step-button",
+ },
+ prefix,
+ });
+
+ const resumeWrapper = this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "resume-button-wrapper",
+ class: "resume-button-wrapper",
+ },
+ prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "button",
+ parent: resumeWrapper,
+ attributes: {
+ id: "resume-button",
+ class: "resume-button",
+ },
+ prefix,
+ });
+
+ return container;
+ }
+
+ destroy() {
+ this.hide();
+ this.markup.destroy();
+ this.env = null;
+ this.lastTarget = null;
+ }
+
+ onClick(target) {
+ const { id } = target;
+ if (!id) {
+ return;
+ }
+
+ if (id.includes("paused-dbg-step-button")) {
+ this.stepOver();
+ } else if (id.includes("paused-dbg-resume-button")) {
+ this.resume();
+ }
+ }
+
+ onMouseMove(target) {
+ // Not an element we care about
+ if (!target || !target.id) {
+ return;
+ }
+
+ // If the user didn't change targets, do nothing
+ if (this.lastTarget && this.lastTarget.id === target.id) {
+ return;
+ }
+
+ if (
+ target.id.includes("step-button") ||
+ target.id.includes("resume-button")
+ ) {
+ // The hover should be applied to the wrapper (icon's parent node)
+ const newTarget = target.parentNode.id.includes("wrapper")
+ ? target.parentNode
+ : target;
+
+ // Remove the hover class if the user has changed buttons
+ if (this.lastTarget && this.lastTarget != newTarget) {
+ this.lastTarget.classList.remove("hover");
+ }
+ newTarget.classList.add("hover");
+ this.lastTarget = newTarget;
+ } else if (this.lastTarget) {
+ // Remove the hover class if the user isn't on a button
+ this.lastTarget.classList.remove("hover");
+ }
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "mousedown":
+ this.onClick(e.target);
+ break;
+ case "DOMMouseScroll":
+ // Prevent scrolling. That's because we only took a screenshot of the viewport, so
+ // scrolling out of the viewport wouldn't draw the expected things. In the future
+ // we can take the screenshot again on scroll, but for now it doesn't seem
+ // important.
+ e.preventDefault();
+ break;
+
+ case "mousemove":
+ this.onMouseMove(e.target);
+ break;
+ }
+ }
+
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ show(reason) {
+ if (this.env.isXUL || !reason) {
+ return false;
+ }
+
+ // Only track mouse movement when the the overlay is shown
+ // Prevents mouse tracking when the user isn't paused
+ const { pageListenerTarget } = this.env;
+ pageListenerTarget.addEventListener("mousemove", this);
+
+ // Show the highlighter's root element.
+ const root = this.getElement("root");
+ root.removeAttribute("hidden");
+ root.setAttribute("overlay", "true");
+
+ // Set the text to appear in the toolbar.
+ const toolbar = this.getElement("toolbar");
+ this.getElement("reason").setTextContent(
+ PausedReasonsBundle.formatValueSync(
+ DEBUGGER_PAUSED_REASONS_L10N_MAPPING[reason]
+ )
+ );
+ toolbar.removeAttribute("hidden");
+
+ // When the debugger pauses execution in a page, events will not be delivered
+ // to any handlers added to elements on that page. So here we use the
+ // document's setSuppressedEventListener interface to still be able to act on mouse
+ // events (they'll be handled by the `handleEvent` method)
+ this.env.window.document.setSuppressedEventListener(this);
+ return true;
+ }
+
+ hide() {
+ if (this.env.isXUL) {
+ return;
+ }
+
+ const { pageListenerTarget } = this.env;
+ pageListenerTarget.removeEventListener("mousemove", this);
+
+ // Hide the overlay.
+ this.getElement("root").setAttribute("hidden", "true");
+ // Remove the hover state
+ this.getElement("step-button-wrapper").classList.remove("hover");
+ this.getElement("resume-button-wrapper").classList.remove("hover");
+ }
+}
+exports.PausedDebuggerOverlay = PausedDebuggerOverlay;
diff --git a/devtools/server/actors/highlighters/remote-node-picker-notice.js b/devtools/server/actors/highlighters/remote-node-picker-notice.js
new file mode 100644
index 0000000000..64b131d2a2
--- /dev/null
+++ b/devtools/server/actors/highlighters/remote-node-picker-notice.js
@@ -0,0 +1,188 @@
+/* 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 {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+loader.lazyGetter(this, "HighlightersBundle", () => {
+ return new Localization(["devtools/shared/highlighters.ftl"], true);
+});
+
+loader.lazyGetter(this, "isAndroid", () => {
+ return Services.appinfo.OS === "Android";
+});
+
+/**
+ * The RemoteNodePickerNotice is a class that displays a notice in a remote debugged page.
+ * This is used to signal to users they can click/tap an element to select it in the
+ * about:devtools-toolbox toolbox inspector.
+ */
+class RemoteNodePickerNotice {
+ #highlighterEnvironment;
+ #previousHoveredElement;
+
+ rootElementId = "node-picker-notice-root";
+ hideButtonId = "node-picker-notice-hide-button";
+ infoNoticeElementId = "node-picker-notice-info";
+
+ /**
+ * @param {highlighterEnvironment} highlighterEnvironment
+ */
+ constructor(highlighterEnvironment) {
+ this.#highlighterEnvironment = highlighterEnvironment;
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.#highlighterEnvironment,
+ this.#buildMarkup
+ );
+ this.isReady = this.markup.initialize();
+ }
+
+ #buildMarkup = () => {
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ // Wrapper element.
+ const wrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: this.rootElementId,
+ hidden: "true",
+ overlay: "true",
+ },
+ });
+
+ const toolbar = this.markup.createNode({
+ parent: wrapper,
+ attributes: {
+ id: "node-picker-notice-toolbar",
+ class: "toolbar",
+ },
+ });
+
+ this.markup.createNode({
+ parent: toolbar,
+ attributes: {
+ id: "node-picker-notice-icon",
+ class: isAndroid ? "touch" : "",
+ },
+ });
+
+ const actionStr = HighlightersBundle.formatValueSync(
+ isAndroid
+ ? "remote-node-picker-notice-action-touch"
+ : "remote-node-picker-notice-action-desktop"
+ );
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: toolbar,
+ text: HighlightersBundle.formatValueSync("remote-node-picker-notice", {
+ action: actionStr,
+ }),
+ attributes: {
+ id: this.infoNoticeElementId,
+ },
+ });
+
+ this.markup.createNode({
+ nodeType: "button",
+ parent: toolbar,
+ text: HighlightersBundle.formatValueSync(
+ "remote-node-picker-notice-hide-button"
+ ),
+ attributes: {
+ id: this.hideButtonId,
+ },
+ });
+
+ return container;
+ };
+
+ destroy() {
+ // hide will nullify take care of this.#abortController.
+ this.hide();
+ this.markup.destroy();
+ this.#highlighterEnvironment = null;
+ this.#previousHoveredElement = null;
+ }
+
+ /**
+ * We can't use event listener directly on the anonymous content because they aren't
+ * working while the page is paused.
+ * This is called from the NodePicker instance for easier events management.
+ *
+ * @param {ClickEvent}
+ */
+ onClick(e) {
+ const target = e.originalTarget || e.target;
+ const targetId = target?.id;
+
+ if (targetId === this.hideButtonId) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Since we can't use :hover in the CSS for the anonymous content as it wouldn't work
+ * when the page is paused, we have to roll our own implementation, adding a `.hover`
+ * class for the element we want to style on hover (e.g. the close button).
+ * This is called from the NodePicker instance for easier events management.
+ *
+ * @param {MouseMoveEvent}
+ */
+ handleHoveredElement(e) {
+ const hideButton = this.markup.getElement(this.hideButtonId);
+
+ const target = e.originalTarget || e.target;
+ const targetId = target?.id;
+
+ // If the user didn't change targets, do nothing
+ if (this.#previousHoveredElement?.id === targetId) {
+ return;
+ }
+
+ if (targetId === this.hideButtonId) {
+ hideButton.classList.add("hover");
+ } else {
+ hideButton.classList.remove("hover");
+ }
+ this.#previousHoveredElement = target;
+ }
+
+ getMarkupRootElement() {
+ return this.markup.getElement(this.rootElementId);
+ }
+
+ async show() {
+ if (this.#highlighterEnvironment.isXUL) {
+ return false;
+ }
+ await this.isReady;
+
+ // Show the highlighter's root element.
+ const root = this.getMarkupRootElement();
+ root.removeAttribute("hidden");
+ root.setAttribute("overlay", "true");
+
+ return true;
+ }
+
+ hide() {
+ if (this.#highlighterEnvironment.isXUL) {
+ return;
+ }
+
+ // Hide the overlay.
+ this.getMarkupRootElement().setAttribute("hidden", "true");
+ // Reset the hover state
+ this.markup.getElement(this.hideButtonId).classList.remove("hover");
+ this.#previousHoveredElement = null;
+ }
+}
+exports.RemoteNodePickerNotice = RemoteNodePickerNotice;
diff --git a/devtools/server/actors/highlighters/rulers.js b/devtools/server/actors/highlighters/rulers.js
new file mode 100644
index 0000000000..b201757d8c
--- /dev/null
+++ b/devtools/server/actors/highlighters/rulers.js
@@ -0,0 +1,312 @@
+/* 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 {
+ getCurrentZoom,
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// Maximum size, in pixel, for the horizontal ruler and vertical ruler
+// used by RulersHighlighter
+const RULERS_MAX_X_AXIS = 10000;
+const RULERS_MAX_Y_AXIS = 15000;
+// Number of steps after we add a graduation, marker and text in
+// RulersHighliter; currently the unit is in pixel.
+const RULERS_GRADUATION_STEP = 5;
+const RULERS_MARKER_STEP = 50;
+const RULERS_TEXT_STEP = 100;
+
+/**
+ * The RulersHighlighter is a class that displays both horizontal and
+ * vertical rules on the page, along the top and left edges, with pixel
+ * graduations, useful for users to quickly check distances
+ */
+class RulersHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("scroll", this);
+ pageListenerTarget.addEventListener("pagehide", this);
+ }
+
+ ID_CLASS_PREFIX = "rulers-highlighter-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const createRuler = (axis, size) => {
+ let width, height;
+ let isHorizontal = true;
+
+ if (axis === "x") {
+ width = size;
+ height = 16;
+ } else if (axis === "y") {
+ width = 16;
+ height = size;
+ isHorizontal = false;
+ } else {
+ throw new Error(
+ `Invalid type of axis given; expected "x" or "y" but got "${axis}"`
+ );
+ }
+
+ const g = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis`,
+ },
+ parent: svg,
+ prefix,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ attributes: {
+ y: isHorizontal ? 0 : 16,
+ width,
+ height,
+ },
+ parent: g,
+ });
+
+ const gRule = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis-ruler`,
+ },
+ parent: g,
+ prefix,
+ });
+
+ const pathGraduations = this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ class: "ruler-graduations",
+ width,
+ height,
+ },
+ parent: gRule,
+ prefix,
+ });
+
+ const pathMarkers = this.markup.createSVGNode({
+ nodeType: "path",
+ attributes: {
+ class: "ruler-markers",
+ width,
+ height,
+ },
+ parent: gRule,
+ prefix,
+ });
+
+ const gText = this.markup.createSVGNode({
+ nodeType: "g",
+ attributes: {
+ id: `${axis}-axis-text`,
+ class: (isHorizontal ? "horizontal" : "vertical") + "-labels",
+ },
+ parent: g,
+ prefix,
+ });
+
+ let dGraduations = "";
+ let dMarkers = "";
+ let graduationLength;
+
+ for (let i = 0; i < size; i += RULERS_GRADUATION_STEP) {
+ if (i === 0) {
+ continue;
+ }
+
+ graduationLength = i % 2 === 0 ? 6 : 4;
+
+ if (i % RULERS_TEXT_STEP === 0) {
+ graduationLength = 8;
+ this.markup.createSVGNode({
+ nodeType: "text",
+ parent: gText,
+ attributes: {
+ x: isHorizontal ? 2 + i : -i - 1,
+ y: 5,
+ },
+ }).textContent = i;
+ }
+
+ if (isHorizontal) {
+ if (i % RULERS_MARKER_STEP === 0) {
+ dMarkers += `M${i} 0 L${i} ${graduationLength}`;
+ } else {
+ dGraduations += `M${i} 0 L${i} ${graduationLength} `;
+ }
+ } else if (i % 50 === 0) {
+ dMarkers += `M0 ${i} L${graduationLength} ${i}`;
+ } else {
+ dGraduations += `M0 ${i} L${graduationLength} ${i}`;
+ }
+ }
+
+ pathGraduations.setAttribute("d", dGraduations);
+ pathMarkers.setAttribute("d", dMarkers);
+
+ return g;
+ };
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ const root = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix,
+ });
+
+ const svg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: root,
+ attributes: {
+ id: "elements",
+ class: "elements",
+ width: "100%",
+ height: "100%",
+ hidden: "true",
+ },
+ prefix,
+ });
+
+ createRuler("x", RULERS_MAX_X_AXIS);
+ createRuler("y", RULERS_MAX_Y_AXIS);
+
+ return container;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "scroll":
+ this._onScroll(event);
+ break;
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (event.target.defaultView === this.env.window) {
+ this.destroy();
+ }
+ break;
+ }
+ }
+
+ _onScroll(event) {
+ const prefix = this.ID_CLASS_PREFIX;
+ const { scrollX, scrollY } = event.view;
+
+ this.markup
+ .getElement(`${prefix}x-axis-ruler`)
+ .setAttribute("transform", `translate(${-scrollX})`);
+ this.markup
+ .getElement(`${prefix}x-axis-text`)
+ .setAttribute("transform", `translate(${-scrollX})`);
+ this.markup
+ .getElement(`${prefix}y-axis-ruler`)
+ .setAttribute("transform", `translate(0, ${-scrollY})`);
+ this.markup
+ .getElement(`${prefix}y-axis-text`)
+ .setAttribute("transform", `translate(0, ${-scrollY})`);
+ }
+
+ _update() {
+ const { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ const zoom = getCurrentZoom(window);
+ const isZoomChanged = zoom !== this._zoom;
+
+ if (isZoomChanged) {
+ this._zoom = zoom;
+ this.updateViewport();
+ }
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ }
+
+ _cancelUpdate() {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ }
+ updateViewport() {
+ const { devicePixelRatio } = this.env.window;
+
+ // Because `devicePixelRatio` is affected by zoom (see bug 809788),
+ // in order to get the "real" device pixel ratio, we need divide by `zoom`
+ const pixelRatio = devicePixelRatio / this._zoom;
+
+ // The "real" device pixel ratio is used to calculate the max stroke
+ // width we can actually assign: on retina, for instance, it would be 0.5,
+ // where on non high dpi monitor would be 1.
+ const minWidth = 1 / pixelRatio;
+ const strokeWidth = Math.min(minWidth, minWidth / this._zoom);
+
+ this.markup
+ .getElement(this.ID_CLASS_PREFIX + "root")
+ .setAttribute("style", `stroke-width:${strokeWidth};`);
+ }
+
+ destroy() {
+ this.hide();
+
+ const { pageListenerTarget } = this.env;
+
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("scroll", this);
+ pageListenerTarget.removeEventListener("pagehide", this);
+ }
+
+ this.markup.destroy();
+
+ EventEmitter.emit(this, "destroy");
+ }
+
+ show() {
+ this.markup.removeAttributeForElement(
+ this.ID_CLASS_PREFIX + "elements",
+ "hidden"
+ );
+
+ this._update();
+
+ return true;
+ }
+
+ hide() {
+ this.markup.setAttributeForElement(
+ this.ID_CLASS_PREFIX + "elements",
+ "hidden",
+ "true"
+ );
+
+ this._cancelUpdate();
+ }
+}
+exports.RulersHighlighter = RulersHighlighter;
diff --git a/devtools/server/actors/highlighters/selector.js b/devtools/server/actors/highlighters/selector.js
new file mode 100644
index 0000000000..249060fd3b
--- /dev/null
+++ b/devtools/server/actors/highlighters/selector.js
@@ -0,0 +1,97 @@
+/* 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 {
+ isNodeValid,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ BoxModelHighlighter,
+} = require("resource://devtools/server/actors/highlighters/box-model.js");
+
+// How many maximum nodes can be highlighted at the same time by the SelectorHighlighter
+const MAX_HIGHLIGHTED_ELEMENTS = 100;
+
+/**
+ * The SelectorHighlighter runs a given selector through querySelectorAll on the
+ * document of the provided context node and then uses the BoxModelHighlighter
+ * to highlight the matching nodes
+ */
+class SelectorHighlighter {
+ constructor(highlighterEnv) {
+ this.highlighterEnv = highlighterEnv;
+ this._highlighters = [];
+ }
+
+ /**
+ * Show a BoxModelHighlighter on each node that matches a given selector.
+ *
+ * @param {DOMNode} node
+ * A context node used to get the document element on which to run
+ * querySelectorAll(). This node will not be highlighted.
+ * @param {Object} options
+ * Configuration options for SelectorHighlighter.
+ * All of the options for BoxModelHighlighter.show() are also valid here.
+ * @param {String} options.selector
+ * Required. CSS selector used with querySelectorAll() to find matching elements.
+ */
+ async show(node, options = {}) {
+ this.hide();
+
+ if (!isNodeValid(node) || !options.selector) {
+ return false;
+ }
+
+ let nodes = [];
+ try {
+ nodes = [...node.ownerDocument.querySelectorAll(options.selector)];
+ } catch (e) {
+ // It's fine if the provided selector is invalid, `nodes` will be an empty array.
+ }
+
+ // Prevent passing the `selector` option to BoxModelHighlighter
+ delete options.selector;
+
+ const promises = [];
+ for (let i = 0; i < Math.min(nodes.length, MAX_HIGHLIGHTED_ELEMENTS); i++) {
+ promises.push(this._showHighlighter(nodes[i], options));
+ }
+
+ await Promise.all(promises);
+ return true;
+ }
+
+ /**
+ * Create an instance of BoxModelHighlighter, wait for it to be ready
+ * (see CanvasFrameAnonymousContentHelper.initialize()),
+ * then show the highlighter on the given node with the given configuration options.
+ *
+ * @param {DOMNode} node
+ * Node to be highlighted
+ * @param {Object} options
+ * Configuration options for the BoxModelHighlighter
+ * @return {Promise} Promise that resolves when the BoxModelHighlighter is ready
+ */
+ async _showHighlighter(node, options) {
+ const highlighter = new BoxModelHighlighter(this.highlighterEnv);
+ await highlighter.isReady;
+
+ highlighter.show(node, options);
+ this._highlighters.push(highlighter);
+ }
+
+ hide() {
+ for (const highlighter of this._highlighters) {
+ highlighter.destroy();
+ }
+ this._highlighters = [];
+ }
+
+ destroy() {
+ this.hide();
+ this.highlighterEnv = null;
+ }
+}
+exports.SelectorHighlighter = SelectorHighlighter;
diff --git a/devtools/server/actors/highlighters/shapes.js b/devtools/server/actors/highlighters/shapes.js
new file mode 100644
index 0000000000..a77e8c31be
--- /dev/null
+++ b/devtools/server/actors/highlighters/shapes.js
@@ -0,0 +1,3263 @@
+/* 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 {
+ CanvasFrameAnonymousContentHelper,
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ setIgnoreLayoutChanges,
+ getCurrentZoom,
+ getAdjustedQuads,
+ getFrameOffsets,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ AutoRefreshHighlighter,
+} = require("resource://devtools/server/actors/highlighters/auto-refresh.js");
+const {
+ getDistance,
+ clickedOnEllipseEdge,
+ distanceToLine,
+ projection,
+ clickedOnPoint,
+} = require("resource://devtools/server/actors/utils/shapes-utils.js");
+const {
+ identity,
+ apply,
+ translate,
+ multiply,
+ scale,
+ rotate,
+ changeMatrixBase,
+ getBasis,
+} = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ getCSSStyleRules,
+} = require("resource://devtools/shared/inspector/css-logic.js");
+
+const BASE_MARKER_SIZE = 5;
+// the width of the area around highlighter lines that can be clicked, in px
+const LINE_CLICK_WIDTH = 5;
+const ROTATE_LINE_LENGTH = 50;
+const DOM_EVENTS = ["mousedown", "mousemove", "mouseup", "dblclick"];
+const _dragging = Symbol("shapes/dragging");
+
+/**
+ * The ShapesHighlighter draws an outline shapes in the page.
+ * The idea is to have something that is able to wrap complex shapes for css properties
+ * such as shape-outside/inside, clip-path but also SVG elements.
+ *
+ * Notes on shape transformation:
+ *
+ * When using transform mode to translate, scale, and rotate shapes, a transformation
+ * matrix keeps track of the transformations done to the original shape. When the
+ * highlighter is toggled on/off or between transform mode and point editing mode,
+ * the transformations applied to the shape become permanent.
+ *
+ * While transformations are being performed on a shape, there is an "original" and
+ * a "transformed" coordinate system. This is used when scaling or rotating a rotated
+ * shape.
+ *
+ * The "original" coordinate system is the one where (0,0) is at the top left corner
+ * of the page, the x axis is horizontal, and the y axis is vertical.
+ *
+ * The "transformed" coordinate system is the one where (0,0) is at the top left
+ * corner of the current shape. The x axis follows the north edge of the shape
+ * (from the northwest corner to the northeast corner) and the y axis follows
+ * the west edge of the shape (from the northwest corner to the southwest corner).
+ *
+ * Because of rotation, the "north" and "west" edges might not actually be at the
+ * top and left of the transformed shape. Imagine that the compass directions are
+ * also rotated along with the shape.
+ *
+ * A refresher for coordinates and change of basis that may be helpful:
+ * https://www.math.ubc.ca/~behrend/math221/Coords.pdf
+ *
+ * @param {String} options.hoverPoint
+ * The point to highlight.
+ * @param {Boolean} options.transformMode
+ * Whether to show the highlighter in transforms mode.
+ * @param {} options.mode
+ */
+class ShapesHighlighter extends AutoRefreshHighlighter {
+ constructor(highlighterEnv) {
+ super(highlighterEnv);
+ EventEmitter.decorate(this);
+
+ this.ID_CLASS_PREFIX = "shapes-";
+
+ this.referenceBox = "border";
+ this.useStrokeBox = false;
+ this.geometryBox = "";
+ this.hoveredPoint = null;
+ this.fillRule = "";
+ this.numInsetPoints = 0;
+ this.transformMode = false;
+ this.viewport = {};
+
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ this.highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+ this.onPageHide = this.onPageHide.bind(this);
+
+ const { pageListenerTarget } = this.highlighterEnv;
+ DOM_EVENTS.forEach(event =>
+ pageListenerTarget.addEventListener(event, this)
+ );
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ _buildMarkup() {
+ const container = this.markup.createNode({
+ attributes: {
+ class: "highlighter-container",
+ },
+ });
+
+ // The root wrapper is used to unzoom the highlighter when needed.
+ const rootWrapper = this.markup.createNode({
+ parent: container,
+ attributes: {
+ id: "root",
+ class: "root",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const mainSvg = this.markup.createSVGNode({
+ nodeType: "svg",
+ parent: rootWrapper,
+ attributes: {
+ id: "shape-container",
+ class: "shape-container",
+ viewBox: "0 0 100 100",
+ preserveAspectRatio: "none",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // This clipPath and its children make sure the element quad outline
+ // is only shown when the shape extends past the element quads.
+ const clipSvg = this.markup.createSVGNode({
+ nodeType: "clipPath",
+ parent: mainSvg,
+ attributes: {
+ id: "clip-path",
+ class: "clip-path",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: clipSvg,
+ attributes: {
+ id: "clip-polygon",
+ class: "clip-polygon",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "ellipse",
+ parent: clipSvg,
+ attributes: {
+ id: "clip-ellipse",
+ class: "clip-ellipse",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: clipSvg,
+ attributes: {
+ id: "clip-rect",
+ class: "clip-rect",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Rectangle that displays the element quads. Only shown for shape-outside.
+ // Only the parts of the rectangle's outline that overlap with the shape is shown.
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: mainSvg,
+ attributes: {
+ id: "quad",
+ class: "quad",
+ hidden: "true",
+ "clip-path": "url(#shapes-clip-path)",
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // clipPath that corresponds to the element's quads. Only applied for shape-outside.
+ // This ensures only the parts of the shape that are within the element's quads are
+ // outlined by a solid line.
+ const shapeClipSvg = this.markup.createSVGNode({
+ nodeType: "clipPath",
+ parent: mainSvg,
+ attributes: {
+ id: "quad-clip-path",
+ class: "quad-clip-path",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: shapeClipSvg,
+ attributes: {
+ id: "quad-clip",
+ class: "quad-clip",
+ x: -1,
+ y: -1,
+ width: 102,
+ height: 102,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ const mainGroup = this.markup.createSVGNode({
+ nodeType: "g",
+ parent: mainSvg,
+ attributes: {
+ id: "group",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append a polygon for polygon shapes.
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: mainGroup,
+ attributes: {
+ id: "polygon",
+ class: "polygon",
+ hidden: "true",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append an ellipse for circle/ellipse shapes.
+ this.markup.createSVGNode({
+ nodeType: "ellipse",
+ parent: mainGroup,
+ attributes: {
+ id: "ellipse",
+ class: "ellipse",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append a rect for inset().
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: mainGroup,
+ attributes: {
+ id: "rect",
+ class: "rect",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Dashed versions of each shape. Only shown for the parts of the shape
+ // that extends past the element's quads.
+ this.markup.createSVGNode({
+ nodeType: "polygon",
+ parent: mainGroup,
+ attributes: {
+ id: "dashed-polygon",
+ class: "polygon",
+ hidden: "true",
+ "stroke-dasharray": "5, 5",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "ellipse",
+ parent: mainGroup,
+ attributes: {
+ id: "dashed-ellipse",
+ class: "ellipse",
+ hidden: "true",
+ "stroke-dasharray": "5, 5",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "rect",
+ parent: mainGroup,
+ attributes: {
+ id: "dashed-rect",
+ class: "rect",
+ hidden: "true",
+ "stroke-dasharray": "5, 5",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "bounding-box",
+ class: "bounding-box",
+ "stroke-dasharray": "5, 5",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "rotate-line",
+ class: "rotate-line",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ // Append a path to display the markers for the shape.
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "markers-outline",
+ class: "markers-outline",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "markers",
+ class: "markers",
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ this.markup.createSVGNode({
+ nodeType: "path",
+ parent: mainGroup,
+ attributes: {
+ id: "marker-hover",
+ class: "marker-hover",
+ hidden: true,
+ },
+ prefix: this.ID_CLASS_PREFIX,
+ });
+
+ return container;
+ }
+
+ get currentDimensions() {
+ let dims = this.currentQuads[this.referenceBox][0].bounds;
+ const zoom = getCurrentZoom(this.win);
+
+ // If an SVG element has a stroke, currentQuads will return the stroke bounding box.
+ // However, clip-path always uses the object bounding box unless "stroke-box" is
+ // specified. So, we must calculate the object bounding box if there is a stroke
+ // and "stroke-box" is not specified. stroke only applies to SVG elements, so use
+ // getBBox, which only exists for SVG, to check if currentNode is an SVG element.
+ if (
+ this.drawingNode.getBBox &&
+ getComputedStyle(this.drawingNode).stroke !== "none" &&
+ !this.useStrokeBox
+ ) {
+ dims = getObjectBoundingBox(
+ dims.top,
+ dims.left,
+ dims.width,
+ dims.height,
+ this.drawingNode
+ );
+ }
+
+ return {
+ top: dims.top / zoom,
+ left: dims.left / zoom,
+ width: dims.width / zoom,
+ height: dims.height / zoom,
+ };
+ }
+
+ get frameDimensions() {
+ // In an iframe, we get the node's quads relative to the frame, instead of the parent
+ // document.
+ let dims =
+ this.highlighterEnv.window.document === this.drawingNode.ownerDocument
+ ? this.currentQuads[this.referenceBox][0].bounds
+ : getAdjustedQuads(
+ this.drawingNode.ownerGlobal,
+ this.drawingNode,
+ this.referenceBox
+ )[0].bounds;
+ const zoom = getCurrentZoom(this.win);
+
+ // If an SVG element has a stroke, currentQuads will return the stroke bounding box.
+ // However, clip-path always uses the object bounding box unless "stroke-box" is
+ // specified. So, we must calculate the object bounding box if there is a stroke
+ // and "stroke-box" is not specified. stroke only applies to SVG elements, so use
+ // getBBox, which only exists for SVG, to check if currentNode is an SVG element.
+ if (
+ this.drawingNode.getBBox &&
+ getComputedStyle(this.drawingNode).stroke !== "none" &&
+ !this.useStrokeBox
+ ) {
+ dims = getObjectBoundingBox(
+ dims.top,
+ dims.left,
+ dims.width,
+ dims.height,
+ this.drawingNode
+ );
+ }
+
+ return {
+ top: dims.top / zoom,
+ left: dims.left / zoom,
+ width: dims.width / zoom,
+ height: dims.height / zoom,
+ };
+ }
+
+ /**
+ * Changes the appearance of the mouse cursor on the highlighter.
+ *
+ * Because we can't attach event handlers to individual elements in the
+ * highlighter, we determine if the mouse is hovering over a point by seeing if
+ * it's within 5 pixels of it. This creates a square hitbox that doesn't match
+ * perfectly with the circular markers. So if we were to use the :hover
+ * pseudo-class to apply changes to the mouse cursor, the cursor change would not
+ * always accurately reflect whether you can interact with the point. This is
+ * also the reason we have the hidden marker-hover element instead of using CSS
+ * to fill in the marker.
+ *
+ * In addition, the cursor CSS property is applied to .shapes-root because if
+ * it were attached to .shapes-marker, the cursor change no longer applies if
+ * you are for example resizing the shape and your mouse goes off the point.
+ * Also, if you are dragging a polygon point, the marker plays catch up to your
+ * mouse position, resulting in an undesirable visual effect where the cursor
+ * rapidly flickers between "grab" and "auto".
+ *
+ * @param {String} cursorType the name of the cursor to display
+ */
+ setCursor(cursorType) {
+ const container = this.getElement("root");
+ let style = container.getAttribute("style");
+ // remove existing cursor definitions in the style
+ style = style.replace(/cursor:.*?;/g, "");
+ style = style.replace(/pointer-events:.*?;/g, "");
+ const pointerEvents = cursorType === "auto" ? "none" : "auto";
+ container.setAttribute(
+ "style",
+ `${style}pointer-events:${pointerEvents};cursor:${cursorType};`
+ );
+ }
+
+ /**
+ * Set the absolute pixel offsets which define the current viewport in relation to
+ * the full page size.
+ *
+ * If a padding value is given, inset the viewport by this value. This is used to define
+ * a virtual viewport which ensures some element remains visible even when at the edges
+ * of the actual viewport.
+ *
+ * @param {Number} padding
+ * Optional. Amount by which to inset the viewport in all directions.
+ */
+ setViewport(padding = 0) {
+ let xOffset = 0;
+ let yOffset = 0;
+
+ // If the node exists within an iframe, get offsets for the virtual viewport so that
+ // points can be dragged to the extent of the global window, outside of the iframe
+ // window.
+ if (this.currentNode.ownerGlobal !== this.win) {
+ const win = this.win;
+ const nodeWin = this.currentNode.ownerGlobal;
+ // Get bounding box of iframe document relative to global document.
+ const bounds = nodeWin.document
+ .getBoxQuads({
+ relativeTo: win.document,
+ createFramesForSuppressedWhitespace: false,
+ })[0]
+ .getBounds();
+ xOffset = bounds.left - nodeWin.scrollX + win.scrollX;
+ yOffset = bounds.top - nodeWin.scrollY + win.scrollY;
+ }
+
+ const { pageXOffset, pageYOffset } = this.win;
+ const { clientHeight, clientWidth } = this.win.document.documentElement;
+ const left = pageXOffset + padding - xOffset;
+ const right = clientWidth + pageXOffset - padding - xOffset;
+ const top = pageYOffset + padding - yOffset;
+ const bottom = clientHeight + pageYOffset - padding - yOffset;
+ this.viewport = { left, right, top, bottom, padding };
+ }
+
+ // eslint-disable-next-line complexity
+ handleEvent(event, id) {
+ // No event handling if the highlighter is hidden
+ if (this.areShapesHidden()) {
+ return;
+ }
+
+ let { target, type, pageX, pageY } = event;
+
+ // For events on highlighted nodes in an iframe, when the event takes place
+ // outside the iframe. Check if event target belongs to the iframe. If it doesn't,
+ // adjust pageX/pageY to be relative to the iframe rather than the parent.
+ const nodeDocument = this.currentNode.ownerDocument;
+ if (target !== nodeDocument && target.ownerDocument !== nodeDocument) {
+ const [xOffset, yOffset] = getFrameOffsets(
+ target.ownerGlobal,
+ this.currentNode
+ );
+ const zoom = getCurrentZoom(this.win);
+ // xOffset/yOffset are relative to the viewport, so first find the top/left
+ // edges of the viewport relative to the page.
+ const viewportLeft = pageX - event.clientX;
+ const viewportTop = pageY - event.clientY;
+ // Also adjust for scrolling in the iframe.
+ const { scrollTop, scrollLeft } = nodeDocument.documentElement;
+ pageX -= viewportLeft + xOffset / zoom - scrollLeft;
+ pageY -= viewportTop + yOffset / zoom - scrollTop;
+ }
+
+ switch (type) {
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.destroy();
+ }
+
+ break;
+ case "mousedown":
+ if (this.transformMode) {
+ this._handleTransformClick(pageX, pageY);
+ } else if (this.shapeType === "polygon") {
+ this._handlePolygonClick(pageX, pageY);
+ } else if (this.shapeType === "circle") {
+ this._handleCircleClick(pageX, pageY);
+ } else if (this.shapeType === "ellipse") {
+ this._handleEllipseClick(pageX, pageY);
+ } else if (this.shapeType === "inset") {
+ this._handleInsetClick(pageX, pageY);
+ }
+ event.stopPropagation();
+ event.preventDefault();
+
+ // Calculate constraints for a virtual viewport which ensures that a dragged
+ // marker remains visible even at the edges of the actual viewport.
+ this.setViewport(BASE_MARKER_SIZE);
+ break;
+ case "mouseup":
+ if (this[_dragging]) {
+ this[_dragging] = null;
+ this._handleMarkerHover(this.hoveredPoint);
+ }
+ break;
+ case "mousemove":
+ if (!this[_dragging]) {
+ this._handleMouseMoveNotDragging(pageX, pageY);
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+
+ // Set constraints for mouse position to ensure dragged marker stays in viewport.
+ const { left, right, top, bottom } = this.viewport;
+ pageX = Math.min(Math.max(left, pageX), right);
+ pageY = Math.min(Math.max(top, pageY), bottom);
+
+ const { point } = this[_dragging];
+ if (this.transformMode) {
+ this._handleTransformMove(pageX, pageY);
+ } else if (this.shapeType === "polygon") {
+ this._handlePolygonMove(pageX, pageY);
+ } else if (this.shapeType === "circle") {
+ this._handleCircleMove(point, pageX, pageY);
+ } else if (this.shapeType === "ellipse") {
+ this._handleEllipseMove(point, pageX, pageY);
+ } else if (this.shapeType === "inset") {
+ this._handleInsetMove(point, pageX, pageY);
+ }
+ break;
+ case "dblclick":
+ if (this.shapeType === "polygon" && !this.transformMode) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const index = this.getPolygonPointAt(percentX, percentY);
+ if (index === -1) {
+ this.getPolygonClickedLine(percentX, percentY);
+ return;
+ }
+
+ this._deletePolygonPoint(index);
+ }
+ break;
+ }
+ }
+
+ /**
+ * Handle a mouse click in transform mode.
+ * @param {Number} pageX the x coordinate of the mouse
+ * @param {Number} pageY the y coordinate of the mouse
+ */
+ _handleTransformClick(pageX, pageY) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const type = this.getTransformPointAt(percentX, percentY);
+ if (!type) {
+ return;
+ }
+
+ if (this.shapeType === "polygon") {
+ this._handlePolygonTransformClick(pageX, pageY, type);
+ } else if (this.shapeType === "circle") {
+ this._handleCircleTransformClick(pageX, pageY, type);
+ } else if (this.shapeType === "ellipse") {
+ this._handleEllipseTransformClick(pageX, pageY, type);
+ } else if (this.shapeType === "inset") {
+ this._handleInsetTransformClick(pageX, pageY, type);
+ }
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting a polygon.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handlePolygonTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const pointsInfo = this.origCoordUnits.map(([x, y], i) => {
+ const xComputed = (this.origCoordinates[i][0] / 100) * width;
+ const yComputed = (this.origCoordinates[i][1] / 100) * height;
+ const unitX = getUnit(x);
+ const unitY = getUnit(y);
+ const valueX = isUnitless(x) ? xComputed : parseFloat(x);
+ const valueY = isUnitless(y) ? yComputed : parseFloat(y);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+ return { unitX, unitY, valueX, valueY, ratioX, ratioY };
+ });
+ this[_dragging] = {
+ type,
+ pointsInfo,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ this._handleMarkerHover(this.hoveredPoint);
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting a circle.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handleCircleTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const { cx, cy } = this.origCoordUnits;
+ const cxComputed = (this.origCoordinates.cx / 100) * width;
+ const cyComputed = (this.origCoordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ let { radius } = this.origCoordinates;
+ const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2);
+ radius = (radius / 100) * computedSize;
+ let valueRad = this.origCoordUnits.radius;
+ const unitRad = getUnit(valueRad);
+ valueRad = isUnitless(valueRad) ? radius : parseFloat(valueRad);
+ const ratioRad = this.getUnitToPixelRatio(unitRad, computedSize);
+
+ this[_dragging] = {
+ type,
+ unitX,
+ unitY,
+ unitRad,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ ratioRad,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting an ellipse.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handleEllipseTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const { cx, cy } = this.origCoordUnits;
+ const cxComputed = (this.origCoordinates.cx / 100) * width;
+ const cyComputed = (this.origCoordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ let { rx, ry } = this.origCoordinates;
+ rx = (rx / 100) * width;
+ let valueRX = this.origCoordUnits.rx;
+ const unitRX = getUnit(valueRX);
+ valueRX = isUnitless(valueRX) ? rx : parseFloat(valueRX);
+ const ratioRX = valueRX / rx || 1;
+ ry = (ry / 100) * height;
+ let valueRY = this.origCoordUnits.ry;
+ const unitRY = getUnit(valueRY);
+ valueRY = isUnitless(valueRY) ? ry : parseFloat(valueRY);
+ const ratioRY = valueRY / ry || 1;
+
+ this[_dragging] = {
+ type,
+ unitX,
+ unitY,
+ unitRX,
+ unitRY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ ratioRX,
+ ratioRY,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ }
+
+ /**
+ * Handle a click in transform mode while highlighting an inset.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @param {String} type the type of transform handle that was clicked.
+ */
+ _handleInsetTransformClick(pageX, pageY, type) {
+ const { width, height } = this.currentDimensions;
+ const pointsInfo = {};
+ ["top", "right", "bottom", "left"].forEach(point => {
+ let value = this.origCoordUnits[point];
+ const size = point === "left" || point === "right" ? width : height;
+ const computedValue = (this.origCoordinates[point] / 100) * size;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? computedValue : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, size);
+
+ pointsInfo[point] = { value, unit, ratio };
+ });
+ this[_dragging] = {
+ type,
+ pointsInfo,
+ x: pageX,
+ y: pageY,
+ bb: this.boundingBox,
+ matrix: this.transformMatrix,
+ transformedBB: this.transformedBoundingBox,
+ };
+ }
+
+ /**
+ * Handle mouse movement after a click on a handle in transform mode.
+ * @param {Number} pageX the x coordinate of the mouse
+ * @param {Number} pageY the y coordinate of the mouse
+ */
+ _handleTransformMove(pageX, pageY) {
+ const { type } = this[_dragging];
+ if (type === "translate") {
+ this._translateShape(pageX, pageY);
+ } else if (type.includes("scale")) {
+ this._scaleShape(pageX, pageY);
+ } else if (type === "rotate" && this.shapeType === "polygon") {
+ this._rotateShape(pageX, pageY);
+ }
+
+ this.transformedBoundingBox = this.calculateTransformedBoundingBox();
+ }
+
+ /**
+ * Translates a shape based on the current mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ */
+ _translateShape(pageX, pageY) {
+ const { x, y, matrix } = this[_dragging];
+ const deltaX = pageX - x;
+ const deltaY = pageY - y;
+ this.transformMatrix = multiply(translate(deltaX, deltaY), matrix);
+
+ if (this.shapeType === "polygon") {
+ this._transformPolygon();
+ } else if (this.shapeType === "circle") {
+ this._transformCircle();
+ } else if (this.shapeType === "ellipse") {
+ this._transformEllipse();
+ } else if (this.shapeType === "inset") {
+ this._transformInset();
+ }
+ }
+
+ /**
+ * Scales a shape according to the current mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ */
+ _scaleShape(pageX, pageY) {
+ /**
+ * To scale a shape:
+ * 1) Get the change of basis matrix corresponding to the current transformation
+ * matrix of the shape.
+ * 2) Convert the mouse x/y deltas to the "transformed" coordinate system, using
+ * the change of base matrix.
+ * 3) Calculate the proportion to which the shape should be scaled to, using the
+ * mouse x/y deltas and the width/height of the transformed shape.
+ * 4) Translate the shape such that the anchor (the point opposite to the one
+ * being dragged) is at the top left of the element.
+ * 5) Scale each point by multiplying by the scaling proportion.
+ * 6) Translate the shape back such that the anchor is in its original position.
+ */
+ const { type, x, y, matrix } = this[_dragging];
+ const { width, height } = this.currentDimensions;
+ // The point opposite to the one being dragged
+ const anchor = getAnchorPoint(type);
+
+ const { ne, nw, sw } = this[_dragging].transformedBB;
+ // u/v are the basis vectors of the transformed coordinate system.
+ const u = [
+ ((ne[0] - nw[0]) / 100) * width,
+ ((ne[1] - nw[1]) / 100) * height,
+ ];
+ const v = [
+ ((sw[0] - nw[0]) / 100) * width,
+ ((sw[1] - nw[1]) / 100) * height,
+ ];
+ // uLength/vLength represent the width/height of the shape in the
+ // transformed coordinate system.
+ const { basis, invertedBasis, uLength, vLength } = getBasis(u, v);
+
+ // How much points on each axis should be translated before scaling
+ const transX = (this[_dragging].transformedBB[anchor][0] / 100) * width;
+ const transY = (this[_dragging].transformedBB[anchor][1] / 100) * height;
+
+ // Distance from original click to current mouse position
+ const distanceX = pageX - x;
+ const distanceY = pageY - y;
+ // Convert from original coordinate system to transformed coordinate system
+ const tDistanceX =
+ invertedBasis[0] * distanceX + invertedBasis[1] * distanceY;
+ const tDistanceY =
+ invertedBasis[3] * distanceX + invertedBasis[4] * distanceY;
+
+ // Proportion of distance to bounding box width/height of shape
+ const proportionX = tDistanceX / uLength;
+ const proportionY = tDistanceY / vLength;
+ // proportionX is positive for size reductions dragging on w/nw/sw,
+ // negative for e/ne/se.
+ const scaleX = type.includes("w") ? 1 - proportionX : 1 + proportionX;
+ // proportionT is positive for size reductions dragging on n/nw/ne,
+ // negative for s/sw/se.
+ const scaleY = type.includes("n") ? 1 - proportionY : 1 + proportionY;
+ // Take the average of scaleX/scaleY for scaling on two axes
+ const scaleXY = (scaleX + scaleY) / 2;
+
+ const translateMatrix = translate(-transX, -transY);
+ let scaleMatrix = identity();
+ // The scale matrices are in the transformed coordinate system. We must convert
+ // them to the original coordinate system before applying it to the transformation
+ // matrix.
+ if (type === "scale-e" || type === "scale-w") {
+ scaleMatrix = changeMatrixBase(scale(scaleX, 1), invertedBasis, basis);
+ } else if (type === "scale-n" || type === "scale-s") {
+ scaleMatrix = changeMatrixBase(scale(1, scaleY), invertedBasis, basis);
+ } else {
+ scaleMatrix = changeMatrixBase(
+ scale(scaleXY, scaleXY),
+ invertedBasis,
+ basis
+ );
+ }
+ const translateBackMatrix = translate(transX, transY);
+ this.transformMatrix = multiply(
+ translateBackMatrix,
+ multiply(scaleMatrix, multiply(translateMatrix, matrix))
+ );
+
+ if (this.shapeType === "polygon") {
+ this._transformPolygon();
+ } else if (this.shapeType === "circle") {
+ this._transformCircle(transX);
+ } else if (this.shapeType === "ellipse") {
+ this._transformEllipse(transX, transY);
+ } else if (this.shapeType === "inset") {
+ this._transformInset();
+ }
+ }
+
+ /**
+ * Rotates a polygon based on the current mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ */
+ _rotateShape(pageX, pageY) {
+ const { matrix } = this[_dragging];
+ const { center, ne, nw, sw } = this[_dragging].transformedBB;
+ const { width, height } = this.currentDimensions;
+ const centerX = (center[0] / 100) * width;
+ const centerY = (center[1] / 100) * height;
+ const { x: pageCenterX, y: pageCenterY } = this.convertPercentToPageCoords(
+ ...center
+ );
+
+ const dx = pageCenterX - pageX;
+ const dy = pageCenterY - pageY;
+
+ const u = [
+ ((ne[0] - nw[0]) / 100) * width,
+ ((ne[1] - nw[1]) / 100) * height,
+ ];
+ const v = [
+ ((sw[0] - nw[0]) / 100) * width,
+ ((sw[1] - nw[1]) / 100) * height,
+ ];
+ const { invertedBasis } = getBasis(u, v);
+
+ const tdx = invertedBasis[0] * dx + invertedBasis[1] * dy;
+ const tdy = invertedBasis[3] * dx + invertedBasis[4] * dy;
+ const angle = Math.atan2(tdx, tdy);
+ const translateMatrix = translate(-centerX, -centerY);
+ const rotateMatrix = rotate(angle);
+ const translateBackMatrix = translate(centerX, centerY);
+ this.transformMatrix = multiply(
+ translateBackMatrix,
+ multiply(rotateMatrix, multiply(translateMatrix, matrix))
+ );
+
+ this._transformPolygon();
+ }
+
+ /**
+ * Transform a polygon depending on the current transformation matrix.
+ */
+ _transformPolygon() {
+ const { pointsInfo } = this[_dragging];
+
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += pointsInfo
+ .map(point => {
+ const { unitX, unitY, valueX, valueY, ratioX, ratioY } = point;
+ const vector = [valueX / ratioX, valueY / ratioY];
+ let [newX, newY] = apply(this.transformMatrix, vector);
+ newX = round(newX * ratioX, unitX);
+ newY = round(newY * ratioY, unitY);
+
+ return `${newX}${unitX} ${newY}${unitY}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+
+ /**
+ * Transform a circle depending on the current transformation matrix.
+ * @param {Number} transX the number of pixels the shape is translated on the x axis
+ * before scaling
+ */
+ _transformCircle(transX = null) {
+ const { unitX, unitY, unitRad, valueX, valueY, ratioX, ratioY, ratioRad } =
+ this[_dragging];
+ let { radius } = this.coordUnits;
+
+ let [newCx, newCy] = apply(this.transformMatrix, [
+ valueX / ratioX,
+ valueY / ratioY,
+ ]);
+ if (transX !== null) {
+ // As part of scaling, the shape is translated to be tangent to the line y=0.
+ // To get the new radius, we translate the new cx back to that point and get
+ // the distance to the line y=0.
+ radius = round(Math.abs((newCx - transX) * ratioRad), unitRad);
+ radius = `${radius}${unitRad}`;
+ }
+
+ newCx = round(newCx * ratioX, unitX);
+ newCy = round(newCy * ratioY, unitY);
+ const circleDef =
+ `circle(${radius} at ${newCx}${unitX} ${newCy}${unitY})` +
+ ` ${this.geometryBox}`.trim();
+ this.emit("highlighter-event", { type: "shape-change", value: circleDef });
+ }
+
+ /**
+ * Transform an ellipse depending on the current transformation matrix.
+ * @param {Number} transX the number of pixels the shape is translated on the x axis
+ * before scaling
+ * @param {Number} transY the number of pixels the shape is translated on the y axis
+ * before scaling
+ */
+ _transformEllipse(transX = null, transY = null) {
+ const {
+ unitX,
+ unitY,
+ unitRX,
+ unitRY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ ratioRX,
+ ratioRY,
+ } = this[_dragging];
+ let { rx, ry } = this.coordUnits;
+
+ let [newCx, newCy] = apply(this.transformMatrix, [
+ valueX / ratioX,
+ valueY / ratioY,
+ ]);
+ if (transX !== null && transY !== null) {
+ // As part of scaling, the shape is translated to be tangent to the lines y=0 & x=0.
+ // To get the new radii, we translate the new center back to that point and get the
+ // distances to the line x=0 and y=0.
+ rx = round(Math.abs((newCx - transX) * ratioRX), unitRX);
+ rx = `${rx}${unitRX}`;
+ ry = round(Math.abs((newCy - transY) * ratioRY), unitRY);
+ ry = `${ry}${unitRY}`;
+ }
+
+ newCx = round(newCx * ratioX, unitX);
+ newCy = round(newCy * ratioY, unitY);
+
+ const centerStr = `${newCx}${unitX} ${newCy}${unitY}`;
+ const ellipseDef =
+ `ellipse(${rx} ${ry} at ${centerStr}) ${this.geometryBox}`.trim();
+ this.emit("highlighter-event", { type: "shape-change", value: ellipseDef });
+ }
+
+ /**
+ * Transform an inset depending on the current transformation matrix.
+ */
+ _transformInset() {
+ const { top, left, right, bottom } = this[_dragging].pointsInfo;
+ const { width, height } = this.currentDimensions;
+
+ const topLeft = [left.value / left.ratio, top.value / top.ratio];
+ let [newLeft, newTop] = apply(this.transformMatrix, topLeft);
+ newLeft = round(newLeft * left.ratio, left.unit);
+ newLeft = `${newLeft}${left.unit}`;
+ newTop = round(newTop * top.ratio, top.unit);
+ newTop = `${newTop}${top.unit}`;
+
+ // Right and bottom values are relative to the right and bottom edges of the
+ // element, so convert to the value relative to the left/top edges before scaling
+ // and convert back.
+ const bottomRight = [
+ width - right.value / right.ratio,
+ height - bottom.value / bottom.ratio,
+ ];
+ let [newRight, newBottom] = apply(this.transformMatrix, bottomRight);
+ newRight = round((width - newRight) * right.ratio, right.unit);
+ newRight = `${newRight}${right.unit}`;
+ newBottom = round((height - newBottom) * bottom.ratio, bottom.unit);
+ newBottom = `${newBottom}${bottom.unit}`;
+
+ let insetDef = this.insetRound
+ ? `inset(${newTop} ${newRight} ${newBottom} ${newLeft} round ${this.insetRound})`
+ : `inset(${newTop} ${newRight} ${newBottom} ${newLeft})`;
+ insetDef += this.geometryBox ? this.geometryBox : "";
+
+ this.emit("highlighter-event", { type: "shape-change", value: insetDef });
+ }
+
+ /**
+ * Handle a click when highlighting a polygon.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handlePolygonClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getPolygonPointAt(percentX, percentY);
+ if (point === -1) {
+ return;
+ }
+
+ const [x, y] = this.coordUnits[point];
+ const xComputed = (this.coordinates[point][0] / 100) * width;
+ const yComputed = (this.coordinates[point][1] / 100) * height;
+ const unitX = getUnit(x);
+ const unitY = getUnit(y);
+ const valueX = isUnitless(x) ? xComputed : parseFloat(x);
+ const valueY = isUnitless(y) ? yComputed : parseFloat(y);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ this.setCursor("grabbing");
+ this[_dragging] = {
+ point,
+ unitX,
+ unitY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ x: pageX,
+ y: pageY,
+ };
+ }
+
+ /**
+ * Update the dragged polygon point with the given x/y coords and update
+ * the element style.
+ * @param {Number} pageX the new x coordinate of the point
+ * @param {Number} pageY the new y coordinate of the point
+ */
+ _handlePolygonMove(pageX, pageY) {
+ const { point, unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } =
+ this[_dragging];
+ const deltaX = (pageX - x) * ratioX;
+ const deltaY = (pageY - y) * ratioY;
+ const newX = round(valueX + deltaX, unitX);
+ const newY = round(valueY + deltaY, unitY);
+
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += this.coordUnits
+ .map((coords, i) => {
+ return i === point
+ ? `${newX}${unitX} ${newY}${unitY}`
+ : `${coords[0]} ${coords[1]}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+
+ /**
+ * Add new point to the polygon defintion and update element style.
+ * TODO: Bug 1436054 - Do not default to percentage unit when inserting new point.
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1436054
+ *
+ * @param {Number} after the index of the point that the new point should be added after
+ * @param {Number} x the x coordinate of the new point
+ * @param {Number} y the y coordinate of the new point
+ */
+ _addPolygonPoint(after, x, y) {
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += this.coordUnits
+ .map((coords, i) => {
+ return i === after
+ ? `${coords[0]} ${coords[1]}, ${x}% ${y}%`
+ : `${coords[0]} ${coords[1]}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.hoveredPoint = after + 1;
+ this._emitHoverEvent(this.hoveredPoint);
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+
+ /**
+ * Remove point from polygon defintion and update the element style.
+ * @param {Number} point the index of the point to delete
+ */
+ _deletePolygonPoint(point) {
+ const coordinates = this.coordUnits.slice();
+ coordinates.splice(point, 1);
+ let polygonDef = this.fillRule ? `${this.fillRule}, ` : "";
+ polygonDef += coordinates
+ .map((coords, i) => {
+ return `${coords[0]} ${coords[1]}`;
+ })
+ .join(", ");
+ polygonDef = `polygon(${polygonDef}) ${this.geometryBox}`.trim();
+
+ this.hoveredPoint = null;
+ this._emitHoverEvent(this.hoveredPoint);
+ this.emit("highlighter-event", { type: "shape-change", value: polygonDef });
+ }
+ /**
+ * Handle a click when highlighting a circle.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handleCircleClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getCirclePointAt(percentX, percentY);
+ if (!point) {
+ return;
+ }
+
+ this.setCursor("grabbing");
+ if (point === "center") {
+ const { cx, cy } = this.coordUnits;
+ const cxComputed = (this.coordinates.cx / 100) * width;
+ const cyComputed = (this.coordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ this[_dragging] = {
+ point,
+ unitX,
+ unitY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ x: pageX,
+ y: pageY,
+ };
+ } else if (point === "radius") {
+ let { radius } = this.coordinates;
+ const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2);
+ radius = (radius / 100) * computedSize;
+ let value = this.coordUnits.radius;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? radius : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, computedSize);
+
+ this[_dragging] = { point, value, origRadius: radius, unit, ratio };
+ }
+ }
+
+ /**
+ * Set the center/radius of the circle according to the mouse position and
+ * update the element style.
+ * @param {String} point either "center" or "radius"
+ * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+ * relative to the element
+ */
+ _handleCircleMove(point, pageX, pageY) {
+ const { radius, cx, cy } = this.coordUnits;
+
+ if (point === "center") {
+ const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } =
+ this[_dragging];
+ const deltaX = (pageX - x) * ratioX;
+ const deltaY = (pageY - y) * ratioY;
+ const newCx = `${round(valueX + deltaX, unitX)}${unitX}`;
+ const newCy = `${round(valueY + deltaY, unitY)}${unitY}`;
+ // if not defined by the user, geometryBox will be an empty string; trim() cleans up
+ const circleDef =
+ `circle(${radius} at ${newCx} ${newCy}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: circleDef,
+ });
+ } else if (point === "radius") {
+ const { value, unit, origRadius, ratio } = this[_dragging];
+ // convert center point to px, then get distance between center and mouse.
+ const { x: pageCx, y: pageCy } = this.convertPercentToPageCoords(
+ this.coordinates.cx,
+ this.coordinates.cy
+ );
+ const newRadiusPx = getDistance(pageCx, pageCy, pageX, pageY);
+
+ const delta = (newRadiusPx - origRadius) * ratio;
+ const newRadius = `${round(value + delta, unit)}${unit}`;
+
+ const position = cx !== "" ? ` at ${cx} ${cy}` : "";
+ const circleDef =
+ `circle(${newRadius}${position}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: circleDef,
+ });
+ }
+ }
+
+ /**
+ * Handle a click when highlighting an ellipse.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handleEllipseClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getEllipsePointAt(percentX, percentY);
+ if (!point) {
+ return;
+ }
+
+ this.setCursor("grabbing");
+ if (point === "center") {
+ const { cx, cy } = this.coordUnits;
+ const cxComputed = (this.coordinates.cx / 100) * width;
+ const cyComputed = (this.coordinates.cy / 100) * height;
+ const unitX = getUnit(cx);
+ const unitY = getUnit(cy);
+ const valueX = isUnitless(cx) ? cxComputed : parseFloat(cx);
+ const valueY = isUnitless(cy) ? cyComputed : parseFloat(cy);
+
+ const ratioX = this.getUnitToPixelRatio(unitX, width);
+ const ratioY = this.getUnitToPixelRatio(unitY, height);
+
+ this[_dragging] = {
+ point,
+ unitX,
+ unitY,
+ valueX,
+ valueY,
+ ratioX,
+ ratioY,
+ x: pageX,
+ y: pageY,
+ };
+ } else if (point === "rx") {
+ let { rx } = this.coordinates;
+ rx = (rx / 100) * width;
+ let value = this.coordUnits.rx;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? rx : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, width);
+
+ this[_dragging] = { point, value, origRadius: rx, unit, ratio };
+ } else if (point === "ry") {
+ let { ry } = this.coordinates;
+ ry = (ry / 100) * height;
+ let value = this.coordUnits.ry;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? ry : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, height);
+
+ this[_dragging] = { point, value, origRadius: ry, unit, ratio };
+ }
+ }
+
+ /**
+ * Set center/rx/ry of the ellispe according to the mouse position and update the
+ * element style.
+ * @param {String} point "center", "rx", or "ry"
+ * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+ * relative to the element
+ */
+ _handleEllipseMove(point, pageX, pageY) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const { rx, ry, cx, cy } = this.coordUnits;
+ const position = cx !== "" ? ` at ${cx} ${cy}` : "";
+
+ if (point === "center") {
+ const { unitX, unitY, valueX, valueY, ratioX, ratioY, x, y } =
+ this[_dragging];
+ const deltaX = (pageX - x) * ratioX;
+ const deltaY = (pageY - y) * ratioY;
+ const newCx = `${round(valueX + deltaX, unitX)}${unitX}`;
+ const newCy = `${round(valueY + deltaY, unitY)}${unitY}`;
+ const ellipseDef =
+ `ellipse(${rx} ${ry} at ${newCx} ${newCy}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: ellipseDef,
+ });
+ } else if (point === "rx") {
+ const { value, unit, origRadius, ratio } = this[_dragging];
+ const newRadiusPercent = Math.abs(percentX - this.coordinates.cx);
+ const { width } = this.currentDimensions;
+ const delta = ((newRadiusPercent / 100) * width - origRadius) * ratio;
+ const newRadius = `${round(value + delta, unit)}${unit}`;
+
+ const ellipseDef =
+ `ellipse(${newRadius} ${ry}${position}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: ellipseDef,
+ });
+ } else if (point === "ry") {
+ const { value, unit, origRadius, ratio } = this[_dragging];
+ const newRadiusPercent = Math.abs(percentY - this.coordinates.cy);
+ const { height } = this.currentDimensions;
+ const delta = ((newRadiusPercent / 100) * height - origRadius) * ratio;
+ const newRadius = `${round(value + delta, unit)}${unit}`;
+
+ const ellipseDef =
+ `ellipse(${rx} ${newRadius}${position}) ${this.geometryBox}`.trim();
+
+ this.emit("highlighter-event", {
+ type: "shape-change",
+ value: ellipseDef,
+ });
+ }
+ }
+
+ /**
+ * Handle a click when highlighting an inset.
+ * @param {Number} pageX the x coordinate of the click
+ * @param {Number} pageY the y coordinate of the click
+ */
+ _handleInsetClick(pageX, pageY) {
+ const { width, height } = this.currentDimensions;
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ const point = this.getInsetPointAt(percentX, percentY);
+ if (!point) {
+ return;
+ }
+
+ this.setCursor("grabbing");
+ let value = this.coordUnits[point];
+ const size = point === "left" || point === "right" ? width : height;
+ const computedValue = (this.coordinates[point] / 100) * size;
+ const unit = getUnit(value);
+ value = isUnitless(value) ? computedValue : parseFloat(value);
+ const ratio = this.getUnitToPixelRatio(unit, size);
+ const origValue = point === "left" || point === "right" ? pageX : pageY;
+
+ this[_dragging] = { point, value, origValue, unit, ratio };
+ }
+
+ /**
+ * Set the top/left/right/bottom of the inset shape according to the mouse position
+ * and update the element style.
+ * @param {String} point "top", "left", "right", or "bottom"
+ * @param {Number} pageX the x coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @param {Number} pageY the y coordinate of the mouse position, in terms of %
+ * relative to the element
+ * @memberof ShapesHighlighter
+ */
+ _handleInsetMove(point, pageX, pageY) {
+ let { top, left, right, bottom } = this.coordUnits;
+ const { value, origValue, unit, ratio } = this[_dragging];
+
+ if (point === "left") {
+ const delta = (pageX - origValue) * ratio;
+ left = `${round(value + delta, unit)}${unit}`;
+ } else if (point === "right") {
+ const delta = (pageX - origValue) * ratio;
+ right = `${round(value - delta, unit)}${unit}`;
+ } else if (point === "top") {
+ const delta = (pageY - origValue) * ratio;
+ top = `${round(value + delta, unit)}${unit}`;
+ } else if (point === "bottom") {
+ const delta = (pageY - origValue) * ratio;
+ bottom = `${round(value - delta, unit)}${unit}`;
+ }
+
+ let insetDef = this.insetRound
+ ? `inset(${top} ${right} ${bottom} ${left} round ${this.insetRound})`
+ : `inset(${top} ${right} ${bottom} ${left})`;
+
+ insetDef += this.geometryBox ? this.geometryBox : "";
+
+ this.emit("highlighter-event", { type: "shape-change", value: insetDef });
+ }
+
+ _handleMouseMoveNotDragging(pageX, pageY) {
+ const { percentX, percentY } = this.convertPageCoordsToPercent(
+ pageX,
+ pageY
+ );
+ if (this.transformMode) {
+ const point = this.getTransformPointAt(percentX, percentY);
+ this.hoveredPoint = point;
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "polygon") {
+ const point = this.getPolygonPointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point !== -1 ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "circle") {
+ const point = this.getCirclePointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "ellipse") {
+ const point = this.getEllipsePointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ } else if (this.shapeType === "inset") {
+ const point = this.getInsetPointAt(percentX, percentY);
+ const oldHoveredPoint = this.hoveredPoint;
+ this.hoveredPoint = point ? point : null;
+ if (this.hoveredPoint !== oldHoveredPoint) {
+ this._emitHoverEvent(this.hoveredPoint);
+ }
+ this._handleMarkerHover(point);
+ }
+ }
+
+ /**
+ * Change the appearance of the given marker when the mouse hovers over it.
+ * @param {String|Number} point if the shape is a polygon, the integer index of the
+ * point being hovered. Otherwise, a string identifying the point being hovered.
+ * Integers < 0 and falsey values excluding 0 indicate no point is being hovered.
+ */
+ _handleMarkerHover(point) {
+ // Hide hover marker for now, will be shown if point is a valid hover target
+ this.getElement("marker-hover").setAttribute("hidden", true);
+ // Catch all falsey values except when point === 0, as that's a valid point
+ if (!point && point !== 0) {
+ this.setCursor("auto");
+ return;
+ }
+ const hoverCursor = this[_dragging] ? "grabbing" : "grab";
+
+ if (this.transformMode) {
+ if (!point) {
+ this.setCursor("auto");
+ return;
+ }
+ const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } =
+ this.transformedBoundingBox;
+
+ const points = [
+ {
+ pointName: "translate",
+ x: center[0],
+ y: center[1],
+ cursor: hoverCursor,
+ },
+ { pointName: "scale-se", x: se[0], y: se[1], anchor: "nw" },
+ { pointName: "scale-ne", x: ne[0], y: ne[1], anchor: "sw" },
+ { pointName: "scale-sw", x: sw[0], y: sw[1], anchor: "ne" },
+ { pointName: "scale-nw", x: nw[0], y: nw[1], anchor: "se" },
+ { pointName: "scale-n", x: n[0], y: n[1], anchor: "s" },
+ { pointName: "scale-s", x: s[0], y: s[1], anchor: "n" },
+ { pointName: "scale-e", x: e[0], y: e[1], anchor: "w" },
+ { pointName: "scale-w", x: w[0], y: w[1], anchor: "e" },
+ {
+ pointName: "rotate",
+ x: rotatePoint[0],
+ y: rotatePoint[1],
+ cursor: hoverCursor,
+ },
+ ];
+
+ for (const { pointName, x, y, cursor, anchor } of points) {
+ if (point === pointName) {
+ this._drawHoverMarker([[x, y]]);
+
+ // If the point is a scale handle, we will need to determine the direction
+ // of the resize cursor based on the position of the handle relative to its
+ // "anchor" (the handle opposite to it).
+ if (pointName.includes("scale")) {
+ const direction = this.getRoughDirection(pointName, anchor);
+ this.setCursor(`${direction}-resize`);
+ } else {
+ this.setCursor(cursor);
+ }
+ }
+ }
+ } else if (this.shapeType === "polygon") {
+ if (point === -1) {
+ this.setCursor("auto");
+ return;
+ }
+ this.setCursor(hoverCursor);
+ this._drawHoverMarker([this.coordinates[point]]);
+ } else if (this.shapeType === "circle") {
+ this.setCursor(hoverCursor);
+
+ const { cx, cy, rx } = this.coordinates;
+ if (point === "radius") {
+ this._drawHoverMarker([[cx + rx, cy]]);
+ } else if (point === "center") {
+ this._drawHoverMarker([[cx, cy]]);
+ }
+ } else if (this.shapeType === "ellipse") {
+ this.setCursor(hoverCursor);
+
+ if (point === "center") {
+ const { cx, cy } = this.coordinates;
+ this._drawHoverMarker([[cx, cy]]);
+ } else if (point === "rx") {
+ const { cx, cy, rx } = this.coordinates;
+ this._drawHoverMarker([[cx + rx, cy]]);
+ } else if (point === "ry") {
+ const { cx, cy, ry } = this.coordinates;
+ this._drawHoverMarker([[cx, cy + ry]]);
+ }
+ } else if (this.shapeType === "inset") {
+ this.setCursor(hoverCursor);
+
+ const { top, right, bottom, left } = this.coordinates;
+ const centerX = (left + (100 - right)) / 2;
+ const centerY = (top + (100 - bottom)) / 2;
+ const points = point.split(",");
+ const coords = points.map(side => {
+ if (side === "top") {
+ return [centerX, top];
+ } else if (side === "right") {
+ return [100 - right, centerY];
+ } else if (side === "bottom") {
+ return [centerX, 100 - bottom];
+ } else if (side === "left") {
+ return [left, centerY];
+ }
+ return null;
+ });
+
+ this._drawHoverMarker(coords);
+ }
+ }
+
+ _drawHoverMarker(points) {
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const path = points
+ .map(([x, y]) => {
+ return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom);
+ })
+ .join(" ");
+
+ const markerHover = this.getElement("marker-hover");
+ markerHover.setAttribute("d", path);
+ markerHover.removeAttribute("hidden");
+ }
+
+ _emitHoverEvent(point) {
+ if (point === null || point === undefined) {
+ this.emit("highlighter-event", {
+ type: "shape-hover-off",
+ });
+ } else {
+ this.emit("highlighter-event", {
+ type: "shape-hover-on",
+ point: point.toString(),
+ });
+ }
+ }
+
+ /**
+ * Convert the given coordinates on the page to percentages relative to the current
+ * element.
+ * @param {Number} pageX the x coordinate on the page
+ * @param {Number} pageY the y coordinate on the page
+ * @returns {Object} object of form {percentX, percentY}, which are the x/y coords
+ * in percentages relative to the element.
+ */
+ convertPageCoordsToPercent(pageX, pageY) {
+ // If the current node is in an iframe, we get dimensions relative to the frame.
+ const dims = this.frameDimensions;
+ const { top, left, width, height } = dims;
+ pageX -= left;
+ pageY -= top;
+ const percentX = (pageX * 100) / width;
+ const percentY = (pageY * 100) / height;
+ return { percentX, percentY };
+ }
+
+ /**
+ * Convert the given x/y coordinates, in percentages relative to the current element,
+ * to pixel coordinates relative to the page
+ * @param {Number} x the x coordinate
+ * @param {Number} y the y coordinate
+ * @returns {Object} object of form {x, y}, which are the x/y coords in pixels
+ * relative to the page
+ *
+ * @memberof ShapesHighlighter
+ */
+ convertPercentToPageCoords(x, y) {
+ const dims = this.frameDimensions;
+ const { top, left, width, height } = dims;
+ x = (x * width) / 100;
+ y = (y * height) / 100;
+ x += left;
+ y += top;
+ return { x, y };
+ }
+
+ /**
+ * Get which transformation should be applied based on the mouse position.
+ * @param {Number} pageX the x coordinate of the mouse.
+ * @param {Number} pageY the y coordinate of the mouse.
+ * @returns {String} a string describing the transformation that should be applied
+ * to the shape.
+ */
+ getTransformPointAt(pageX, pageY) {
+ const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } =
+ this.transformedBoundingBox;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ const points = [
+ { pointName: "translate", x: center[0], y: center[1] },
+ { pointName: "scale-se", x: se[0], y: se[1] },
+ { pointName: "scale-ne", x: ne[0], y: ne[1] },
+ { pointName: "scale-sw", x: sw[0], y: sw[1] },
+ { pointName: "scale-nw", x: nw[0], y: nw[1] },
+ ];
+
+ if (this.shapeType === "polygon" || this.shapeType === "ellipse") {
+ points.push(
+ { pointName: "scale-n", x: n[0], y: n[1] },
+ { pointName: "scale-s", x: s[0], y: s[1] },
+ { pointName: "scale-e", x: e[0], y: e[1] },
+ { pointName: "scale-w", x: w[0], y: w[1] }
+ );
+ }
+
+ if (this.shapeType === "polygon") {
+ const x = rotatePoint[0];
+ const y = rotatePoint[1];
+ if (
+ pageX >= x - clickRadiusX &&
+ pageX <= x + clickRadiusX &&
+ pageY >= y - clickRadiusY &&
+ pageY <= y + clickRadiusY
+ ) {
+ return "rotate";
+ }
+ }
+
+ for (const { pointName, x, y } of points) {
+ if (
+ pageX >= x - clickRadiusX &&
+ pageX <= x + clickRadiusX &&
+ pageY >= y - clickRadiusY &&
+ pageY <= y + clickRadiusY
+ ) {
+ return pointName;
+ }
+ }
+
+ return "";
+ }
+
+ /**
+ * Get the id of the point on the polygon highlighter at the given coordinate.
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {Number} the index of the point that was clicked on in this.coordinates,
+ * or -1 if none of the points were clicked on.
+ */
+ getPolygonPointAt(pageX, pageY) {
+ const { coordinates } = this;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ for (const [index, coord] of coordinates.entries()) {
+ const [x, y] = coord;
+ if (
+ pageX >= x - clickRadiusX &&
+ pageX <= x + clickRadiusX &&
+ pageY >= y - clickRadiusY &&
+ pageY <= y + clickRadiusY
+ ) {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Check if the mouse clicked on a line of the polygon, and if so, add a point near
+ * the click.
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ */
+ getPolygonClickedLine(pageX, pageY) {
+ const { coordinates } = this;
+ const { width } = this.currentDimensions;
+ const clickWidth = (LINE_CLICK_WIDTH * 100) / width;
+
+ for (let i = 0; i < coordinates.length; i++) {
+ const [x1, y1] = coordinates[i];
+ const [x2, y2] =
+ i === coordinates.length - 1 ? coordinates[0] : coordinates[i + 1];
+ // Get the distance between clicked point and line drawn between points 1 and 2
+ // to check if the click was on the line between those two points.
+ const distance = distanceToLine(x1, y1, x2, y2, pageX, pageY);
+ if (
+ distance <= clickWidth &&
+ Math.min(x1, x2) - clickWidth <= pageX &&
+ pageX <= Math.max(x1, x2) + clickWidth &&
+ Math.min(y1, y2) - clickWidth <= pageY &&
+ pageY <= Math.max(y1, y2) + clickWidth
+ ) {
+ // Get the point on the line closest to the clicked point.
+ const [newX, newY] = projection(x1, y1, x2, y2, pageX, pageY);
+ // Default unit for new points is percentages
+ this._addPolygonPoint(i, round(newX, "%"), round(newY, "%"));
+ return;
+ }
+ }
+ }
+
+ /**
+ * Check if the center point or radius of the circle highlighter is at given coords
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {String} "center" if the center point was clicked, "radius" if the radius
+ * was clicked, "" if neither was clicked.
+ */
+ getCirclePointAt(pageX, pageY) {
+ const { cx, cy, rx, ry } = this.coordinates;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) {
+ return "center";
+ }
+
+ const clickWidthX = (LINE_CLICK_WIDTH * 100) / width;
+ const clickWidthY = (LINE_CLICK_WIDTH * 100) / height;
+ if (
+ clickedOnEllipseEdge(
+ pageX,
+ pageY,
+ cx,
+ cy,
+ rx,
+ ry,
+ clickWidthX,
+ clickWidthY
+ ) ||
+ clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)
+ ) {
+ return "radius";
+ }
+
+ return "";
+ }
+
+ /**
+ * Check if the center or rx/ry points of the ellipse highlighter is at given point
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {String} "center" if the center point was clicked, "rx" if the x-radius
+ * point was clicked, "ry" if the y-radius point was clicked,
+ * "" if none was clicked.
+ */
+ getEllipsePointAt(pageX, pageY) {
+ const { cx, cy, rx, ry } = this.coordinates;
+ const { width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+
+ if (clickedOnPoint(pageX, pageY, cx, cy, clickRadiusX, clickRadiusY)) {
+ return "center";
+ }
+
+ if (clickedOnPoint(pageX, pageY, cx + rx, cy, clickRadiusX, clickRadiusY)) {
+ return "rx";
+ }
+
+ if (clickedOnPoint(pageX, pageY, cx, cy + ry, clickRadiusX, clickRadiusY)) {
+ return "ry";
+ }
+
+ return "";
+ }
+
+ /**
+ * Check if the edges of the inset highlighter is at given coords
+ * @param {Number} pageX the x coordinate on the page, in % relative to the element
+ * @param {Number} pageY the y coordinate on the page, in % relative to the element
+ * @returns {String} "top", "left", "right", or "bottom" if any of those edges were
+ * clicked. "" if none were clicked.
+ */
+ // eslint-disable-next-line complexity
+ getInsetPointAt(pageX, pageY) {
+ const { top, left, right, bottom } = this.coordinates;
+ const zoom = getCurrentZoom(this.win);
+ const { width, height } = this.currentDimensions;
+ const clickWidthX = (LINE_CLICK_WIDTH * 100) / width;
+ const clickWidthY = (LINE_CLICK_WIDTH * 100) / height;
+ const clickRadiusX = ((BASE_MARKER_SIZE / zoom) * 100) / width;
+ const clickRadiusY = ((BASE_MARKER_SIZE / zoom) * 100) / height;
+ const centerX = (left + (100 - right)) / 2;
+ const centerY = (top + (100 - bottom)) / 2;
+
+ if (
+ (pageX >= left - clickWidthX &&
+ pageX <= left + clickWidthX &&
+ pageY >= top &&
+ pageY <= 100 - bottom) ||
+ clickedOnPoint(pageX, pageY, left, centerY, clickRadiusX, clickRadiusY)
+ ) {
+ return "left";
+ }
+
+ if (
+ (pageX >= 100 - right - clickWidthX &&
+ pageX <= 100 - right + clickWidthX &&
+ pageY >= top &&
+ pageY <= 100 - bottom) ||
+ clickedOnPoint(
+ pageX,
+ pageY,
+ 100 - right,
+ centerY,
+ clickRadiusX,
+ clickRadiusY
+ )
+ ) {
+ return "right";
+ }
+
+ if (
+ (pageY >= top - clickWidthY &&
+ pageY <= top + clickWidthY &&
+ pageX >= left &&
+ pageX <= 100 - right) ||
+ clickedOnPoint(pageX, pageY, centerX, top, clickRadiusX, clickRadiusY)
+ ) {
+ return "top";
+ }
+
+ if (
+ (pageY >= 100 - bottom - clickWidthY &&
+ pageY <= 100 - bottom + clickWidthY &&
+ pageX >= left &&
+ pageX <= 100 - right) ||
+ clickedOnPoint(
+ pageX,
+ pageY,
+ centerX,
+ 100 - bottom,
+ clickRadiusX,
+ clickRadiusY
+ )
+ ) {
+ return "bottom";
+ }
+
+ return "";
+ }
+
+ /**
+ * Parses the CSS definition given and returns the shape type associated
+ * with the definition and the coordinates necessary to draw the shape.
+ * @param {String} definition the input CSS definition
+ * @returns {Object} null if the definition is not of a known shape type,
+ * or an object of the type { shapeType, coordinates }, where
+ * shapeType is the name of the shape and coordinates are an array
+ * or object of the coordinates needed to draw the shape.
+ */
+ _parseCSSShapeValue(definition) {
+ const shapeTypes = [
+ {
+ name: "polygon",
+ prefix: "polygon(",
+ coordParser: this.polygonPoints.bind(this),
+ },
+ {
+ name: "circle",
+ prefix: "circle(",
+ coordParser: this.circlePoints.bind(this),
+ },
+ {
+ name: "ellipse",
+ prefix: "ellipse(",
+ coordParser: this.ellipsePoints.bind(this),
+ },
+ {
+ name: "inset",
+ prefix: "inset(",
+ coordParser: this.insetPoints.bind(this),
+ },
+ ];
+ const geometryTypes = ["margin", "border", "padding", "content"];
+ // default to border for clip-path and offset-path, and margin for shape-outside
+ const defaultGeometryTypesByProperty = new Map([
+ ["clip-path", "border"],
+ ["offset-path", "border"],
+ ["shape-outside", "margin"],
+ ]);
+
+ let referenceBox = defaultGeometryTypesByProperty.get(this.property);
+ for (const geometry of geometryTypes) {
+ if (definition.includes(geometry)) {
+ referenceBox = geometry;
+ }
+ }
+ this.referenceBox = referenceBox;
+
+ this.useStrokeBox = definition.includes("stroke-box");
+ this.geometryBox = definition
+ .substring(definition.lastIndexOf(")") + 1)
+ .trim();
+
+ for (const { name, prefix, coordParser } of shapeTypes) {
+ if (definition.includes(prefix)) {
+ // the closing paren of the shape function is always the last one in definition.
+ definition = definition.substring(
+ prefix.length,
+ definition.lastIndexOf(")")
+ );
+ return {
+ shapeType: name,
+ coordinates: coordParser(definition),
+ };
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Parses the definition of the CSS polygon() function and returns its points,
+ * converted to percentages.
+ * @param {String} definition the arguments of the polygon() function
+ * @returns {Array} an array of the points of the polygon, with all values
+ * evaluated and converted to percentages
+ */
+ polygonPoints(definition) {
+ this.coordUnits = this.polygonRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+ const splitDef = definition.split(", ");
+ if (splitDef[0] === "evenodd" || splitDef[0] === "nonzero") {
+ splitDef.shift();
+ }
+ let minX = Number.MAX_SAFE_INTEGER;
+ let minY = Number.MAX_SAFE_INTEGER;
+ let maxX = Number.MIN_SAFE_INTEGER;
+ let maxY = Number.MIN_SAFE_INTEGER;
+ const coordinates = splitDef.map(coords => {
+ const [x, y] = splitCoords(coords).map(
+ this.convertCoordsToPercent.bind(this)
+ );
+ if (x < minX) {
+ minX = x;
+ }
+ if (y < minY) {
+ minY = y;
+ }
+ if (x > maxX) {
+ maxX = x;
+ }
+ if (y > maxY) {
+ maxY = y;
+ }
+ return [x, y];
+ });
+ this.boundingBox = { minX, minY, maxX, maxY };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return coordinates;
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS polygon.
+ * @returns {Array} an array of the points of the polygon, with units preserved.
+ */
+ polygonRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(8, definition.lastIndexOf(")"));
+ const splitDef = definition.split(", ");
+ if (splitDef[0].includes("evenodd") || splitDef[0].includes("nonzero")) {
+ this.fillRule = splitDef[0].trim();
+ splitDef.shift();
+ } else {
+ this.fillRule = "";
+ }
+ return splitDef.map(coords => {
+ return splitCoords(coords).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ });
+ });
+ }
+
+ /**
+ * Parses the definition of the CSS circle() function and returns the x/y radiuses and
+ * center coordinates, converted to percentages.
+ * @param {String} definition the arguments of the circle() function
+ * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the
+ * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the
+ * center of the circle. All values are evaluated and converted to percentages.
+ */
+ circlePoints(definition) {
+ this.coordUnits = this.circleRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+
+ const values = definition.split("at");
+ let radius = values[0] ? values[0].trim() : "closest-side";
+ const { width, height } = this.currentDimensions;
+ // This defaults to center if omitted.
+ const position = values[1] || "50% 50%";
+ const center = splitCoords(position).map(
+ this.convertCoordsToPercent.bind(this)
+ );
+
+ // Percentage values for circle() are resolved from the
+ // used width and height of the reference box as sqrt(width^2+height^2)/sqrt(2).
+ const computedSize = Math.sqrt(width ** 2 + height ** 2) / Math.sqrt(2);
+
+ // Position coordinates for circle center in pixels.
+ const cxPx = (width * center[0]) / 100;
+ const cyPx = (height * center[1]) / 100;
+
+ if (radius === "closest-side") {
+ // radius is the distance from center to closest side of reference box
+ radius = Math.min(cxPx, cyPx, width - cxPx, height - cyPx);
+ radius = coordToPercent(`${radius}px`, computedSize);
+ } else if (radius === "farthest-side") {
+ // radius is the distance from center to farthest side of reference box
+ radius = Math.max(cxPx, cyPx, width - cxPx, height - cyPx);
+ radius = coordToPercent(`${radius}px`, computedSize);
+ } else if (radius.includes("calc(")) {
+ radius = evalCalcExpression(
+ radius.substring(5, radius.length - 1),
+ computedSize
+ );
+ } else {
+ radius = coordToPercent(radius, computedSize);
+ }
+
+ // Scale both radiusX and radiusY to match the radius computed
+ // using the above equation.
+ const ratioX = width / computedSize;
+ const ratioY = height / computedSize;
+ const radiusX = radius / ratioX;
+ const radiusY = radius / ratioY;
+
+ this.boundingBox = {
+ minX: center[0] - radiusX,
+ maxX: center[0] + radiusX,
+ minY: center[1] - radiusY,
+ maxY: center[1] + radiusY,
+ };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return { radius, rx: radiusX, ry: radiusY, cx: center[0], cy: center[1] };
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS circle.
+ * @returns {Object} an object of the points of the circle (cx, cy, radius),
+ * with units preserved.
+ */
+ circleRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(7, definition.lastIndexOf(")"));
+
+ const values = definition.split("at");
+ const [cx = "", cy = ""] = values[1]
+ ? splitCoords(values[1]).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ })
+ : [];
+ const radius = values[0] ? values[0].trim() : "closest-side";
+ return { cx, cy, radius };
+ }
+
+ /**
+ * Parses the computed style definition of the CSS ellipse() function and returns the
+ * x/y radii and center coordinates, converted to percentages.
+ * @param {String} definition the arguments of the ellipse() function
+ * @returns {Object} an object of the form { rx, ry, cx, cy }, where rx and ry are the
+ * radiuses for the x and y axes, and cx and cy are the x/y coordinates for the
+ * center of the ellipse. All values are evaluated and converted to percentages
+ */
+ ellipsePoints(definition) {
+ this.coordUnits = this.ellipseRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+
+ const values = definition.split("at");
+ // This defaults to center if omitted.
+ const position = values[1] || "50% 50%";
+ const center = splitCoords(position).map(
+ this.convertCoordsToPercent.bind(this)
+ );
+
+ let radii = values[0] ? values[0].trim() : "closest-side closest-side";
+ radii = splitCoords(radii).map((radius, i) => {
+ if (radius === "closest-side") {
+ // radius is the distance from center to closest x/y side of reference box
+ return i % 2 === 0
+ ? Math.min(center[0], 100 - center[0])
+ : Math.min(center[1], 100 - center[1]);
+ } else if (radius === "farthest-side") {
+ // radius is the distance from center to farthest x/y side of reference box
+ return i % 2 === 0
+ ? Math.max(center[0], 100 - center[0])
+ : Math.max(center[1], 100 - center[1]);
+ }
+ return this.convertCoordsToPercent(radius, i);
+ });
+
+ this.boundingBox = {
+ minX: center[0] - radii[0],
+ maxX: center[0] + radii[0],
+ minY: center[1] - radii[1],
+ maxY: center[1] + radii[1],
+ };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return { rx: radii[0], ry: radii[1], cx: center[0], cy: center[1] };
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS ellipse.
+ * @returns {Object} an object of the points of the ellipse (cx, cy, rx, ry),
+ * with units preserved.
+ */
+ ellipseRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(8, definition.lastIndexOf(")"));
+
+ const values = definition.split("at");
+ const [rx = "closest-side", ry = "closest-side"] = values[0]
+ ? splitCoords(values[0]).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ })
+ : [];
+ const [cx = "", cy = ""] = values[1]
+ ? splitCoords(values[1]).map(coord => {
+ return coord.replace(/\u00a0/g, " ");
+ })
+ : [];
+ return { rx, ry, cx, cy };
+ }
+
+ /**
+ * Parses the definition of the CSS inset() function and returns the x/y offsets and
+ * width/height of the shape, converted to percentages. Border radiuses (given after
+ * "round" in the definition) are currently ignored.
+ * @param {String} definition the arguments of the inset() function
+ * @returns {Object} an object of the form { x, y, width, height }, which are the top/
+ * left positions and width/height of the shape.
+ */
+ insetPoints(definition) {
+ this.coordUnits = this.insetRawPoints();
+ if (!this.origCoordUnits) {
+ this.origCoordUnits = this.coordUnits;
+ }
+ const values = definition.split(" round ");
+ const offsets = splitCoords(values[0]);
+
+ let top, left, right, bottom;
+ // The offsets, like margin/padding/border, are in order: top, right, bottom, left.
+ if (offsets.length === 1) {
+ top = left = right = bottom = offsets[0];
+ } else if (offsets.length === 2) {
+ top = bottom = offsets[0];
+ left = right = offsets[1];
+ } else if (offsets.length === 3) {
+ top = offsets[0];
+ left = right = offsets[1];
+ bottom = offsets[2];
+ } else if (offsets.length === 4) {
+ top = offsets[0];
+ right = offsets[1];
+ bottom = offsets[2];
+ left = offsets[3];
+ }
+
+ top = this.convertCoordsToPercentFromCurrentDimension(top, "height");
+ bottom = this.convertCoordsToPercentFromCurrentDimension(bottom, "height");
+ left = this.convertCoordsToPercentFromCurrentDimension(left, "width");
+ right = this.convertCoordsToPercentFromCurrentDimension(right, "width");
+
+ // maxX/maxY are found by subtracting the right/bottom edges from 100
+ // (the width/height of the element in %)
+ this.boundingBox = {
+ minX: left,
+ maxX: 100 - right,
+ minY: top,
+ maxY: 100 - bottom,
+ };
+ if (!this.origBoundingBox) {
+ this.origBoundingBox = this.boundingBox;
+ }
+ return { top, left, right, bottom };
+ }
+
+ /**
+ * Parse the raw (non-computed) definition of the CSS inset.
+ * @returns {Object} an object of the points of the inset (top, right, bottom, left),
+ * with units preserved.
+ */
+ insetRawPoints() {
+ let definition = getDefinedShapeProperties(this.currentNode, this.property);
+ if (definition === this.rawDefinition && this.coordUnits) {
+ return this.coordUnits;
+ }
+ this.rawDefinition = definition;
+ definition = definition.substring(6, definition.lastIndexOf(")"));
+
+ const values = definition.split(" round ");
+ this.insetRound = values[1];
+ const offsets = splitCoords(values[0]).map(coord => {
+ // Undo the insertion of &nbsp; that was done in splitCoords.
+ return coord.replace(/\u00a0/g, " ");
+ });
+
+ let top,
+ left,
+ right,
+ bottom = 0;
+
+ if (offsets.length === 1) {
+ top = left = right = bottom = offsets[0];
+ } else if (offsets.length === 2) {
+ top = bottom = offsets[0];
+ left = right = offsets[1];
+ } else if (offsets.length === 3) {
+ top = offsets[0];
+ left = right = offsets[1];
+ bottom = offsets[2];
+ } else if (offsets.length === 4) {
+ top = offsets[0];
+ right = offsets[1];
+ bottom = offsets[2];
+ left = offsets[3];
+ }
+
+ return { top, left, right, bottom };
+ }
+
+ /**
+ * This uses the index to decide whether to use width or height for the
+ * computation. See `convertCoordsToPercentFromCurrentDimension()` if you
+ * need to specify width or height.
+ * @param {Number} coord a single coordinate
+ * @param {Number} i the index of its position in the function
+ * @returns {Number} the coordinate as a percentage value
+ */
+ convertCoordsToPercent(coord, i) {
+ const { width, height } = this.currentDimensions;
+ const size = i % 2 === 0 ? width : height;
+ if (coord.includes("calc(")) {
+ return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+ }
+ return coordToPercent(coord, size);
+ }
+
+ /**
+ * Converts a value to percent based on the specified dimension.
+ * @param {Number} coord a single coordinate
+ * @param {Number} currentDimensionProperty the dimension ("width" or
+ * "height") to base the calculation off of
+ * @returns {Number} the coordinate as a percentage value
+ */
+ convertCoordsToPercentFromCurrentDimension(coord, currentDimensionProperty) {
+ const size = this.currentDimensions[currentDimensionProperty];
+ if (coord.includes("calc(")) {
+ return evalCalcExpression(coord.substring(5, coord.length - 1), size);
+ }
+ return coordToPercent(coord, size);
+ }
+
+ /**
+ * Destroy the nodes. Remove listeners.
+ */
+ destroy() {
+ const { pageListenerTarget } = this.highlighterEnv;
+ if (pageListenerTarget) {
+ DOM_EVENTS.forEach(type =>
+ pageListenerTarget.removeEventListener(type, this)
+ );
+ }
+ super.destroy(this);
+ this.markup.destroy();
+ }
+
+ /**
+ * Get the element in the highlighter markup with the given id
+ * @param {String} id
+ * @returns {Object} the element with the given id
+ */
+ getElement(id) {
+ return this.markup.getElement(this.ID_CLASS_PREFIX + id);
+ }
+
+ /**
+ * Return whether all the elements used to draw shapes are hidden.
+ * @returns {Boolean}
+ */
+ areShapesHidden() {
+ return (
+ this.getElement("ellipse").hasAttribute("hidden") &&
+ this.getElement("polygon").hasAttribute("hidden") &&
+ this.getElement("rect").hasAttribute("hidden") &&
+ this.getElement("bounding-box").hasAttribute("hidden")
+ );
+ }
+
+ /**
+ * Show the highlighter on a given node
+ */
+ _show() {
+ this.hoveredPoint = this.options.hoverPoint;
+ this.transformMode = this.options.transformMode;
+ this.coordinates = null;
+ this.coordUnits = null;
+ this.origBoundingBox = null;
+ this.origCoordUnits = null;
+ this.origCoordinates = null;
+ this.transformedBoundingBox = null;
+ if (this.transformMode) {
+ this.transformMatrix = identity();
+ }
+ if (this._hasMoved() && this.transformMode) {
+ this.transformedBoundingBox = this.calculateTransformedBoundingBox();
+ }
+ return this._update();
+ }
+
+ /**
+ * The AutoRefreshHighlighter's _hasMoved method returns true only if the element's
+ * quads have changed. Override it so it also returns true if the element's shape has
+ * changed (which can happen when you change a CSS properties for instance).
+ */
+ _hasMoved() {
+ let hasMoved = AutoRefreshHighlighter.prototype._hasMoved.call(this);
+
+ if (hasMoved) {
+ this.origBoundingBox = null;
+ this.origCoordUnits = null;
+ this.origCoordinates = null;
+ if (this.transformMode) {
+ this.transformMatrix = identity();
+ }
+ }
+
+ const oldShapeCoordinates = JSON.stringify(this.coordinates);
+
+ // TODO: need other modes too.
+ if (this.options.mode.startsWith("css")) {
+ const property = shapeModeToCssPropertyName(this.options.mode);
+ // change camelCase to kebab-case
+ this.property = property.replace(/([a-z][A-Z])/g, g => {
+ return g[0] + "-" + g[1].toLowerCase();
+ });
+ const style = getComputedStyle(this.currentNode)[property];
+
+ if (!style || style === "none") {
+ this.coordinates = [];
+ this.shapeType = "none";
+ } else {
+ const { coordinates, shapeType } = this._parseCSSShapeValue(style);
+ this.coordinates = coordinates;
+ if (!this.origCoordinates) {
+ this.origCoordinates = coordinates;
+ }
+ this.shapeType = shapeType;
+ }
+ }
+
+ const newShapeCoordinates = JSON.stringify(this.coordinates);
+ hasMoved = hasMoved || oldShapeCoordinates !== newShapeCoordinates;
+ if (this.transformMode && hasMoved) {
+ this.transformedBoundingBox = this.calculateTransformedBoundingBox();
+ }
+
+ return hasMoved;
+ }
+
+ /**
+ * Hide all elements used to highlight CSS different shapes.
+ */
+ _hideShapes() {
+ this.getElement("ellipse").setAttribute("hidden", true);
+ this.getElement("polygon").setAttribute("hidden", true);
+ this.getElement("rect").setAttribute("hidden", true);
+ this.getElement("bounding-box").setAttribute("hidden", true);
+ this.getElement("markers").setAttribute("d", "");
+ this.getElement("markers-outline").setAttribute("d", "");
+ this.getElement("rotate-line").setAttribute("d", "");
+ this.getElement("quad").setAttribute("hidden", true);
+ this.getElement("clip-ellipse").setAttribute("hidden", true);
+ this.getElement("clip-polygon").setAttribute("hidden", true);
+ this.getElement("clip-rect").setAttribute("hidden", true);
+ this.getElement("dashed-polygon").setAttribute("hidden", true);
+ this.getElement("dashed-ellipse").setAttribute("hidden", true);
+ this.getElement("dashed-rect").setAttribute("hidden", true);
+ }
+
+ /**
+ * Update the highlighter for the current node. Called whenever the element's quads
+ * or CSS shape has changed.
+ * @returns {Boolean} whether the highlighter was successfully updated
+ */
+ _update() {
+ setIgnoreLayoutChanges(true);
+ this.getElement("group").setAttribute("transform", "");
+ const root = this.getElement("root");
+ root.setAttribute("hidden", true);
+
+ const { top, left, width, height } = this.currentDimensions;
+ const zoom = getCurrentZoom(this.win);
+
+ // Size the SVG like the current node.
+ this.getElement("shape-container").setAttribute(
+ "style",
+ `top:${top}px;left:${left}px;width:${width}px;height:${height}px;`
+ );
+
+ this._hideShapes();
+ this._updateShapes(width, height, zoom);
+
+ // For both shape-outside and clip-path the element's quads are displayed for the
+ // parts that overlap with the shape. The parts of the shape that extend past the
+ // element's quads are shown with a dashed line.
+ const quadRect = this.getElement("quad");
+ quadRect.removeAttribute("hidden");
+
+ this.getElement("polygon").setAttribute(
+ "clip-path",
+ "url(#shapes-quad-clip-path)"
+ );
+ this.getElement("ellipse").setAttribute(
+ "clip-path",
+ "url(#shapes-quad-clip-path)"
+ );
+ this.getElement("rect").setAttribute(
+ "clip-path",
+ "url(#shapes-quad-clip-path)"
+ );
+
+ const { width: winWidth, height: winHeight } = this._winDimensions;
+ root.removeAttribute("hidden");
+ root.setAttribute(
+ "style",
+ `position:absolute; width:${winWidth}px;height:${winHeight}px; overflow:hidden;`
+ );
+
+ this._handleMarkerHover(this.hoveredPoint);
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+
+ return true;
+ }
+
+ /**
+ * Update the SVGs to render the current CSS shape and add markers depending on shape
+ * type and transform mode.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateShapes(width, height, zoom) {
+ if (this.transformMode && this.shapeType !== "none") {
+ this._updateTransformMode(width, height, zoom);
+ } else if (this.shapeType === "polygon") {
+ this._updatePolygonShape(width, height, zoom);
+ // Draw markers for each of the polygon's points.
+ this._drawMarkers(this.coordinates, width, height, zoom);
+ } else if (this.shapeType === "circle") {
+ const { rx, cx, cy } = this.coordinates;
+ // Shape renders for "circle()" and "ellipse()" use the same SVG nodes.
+ this._updateEllipseShape(width, height, zoom);
+ // Draw markers for center and radius points.
+ this._drawMarkers(
+ [
+ [cx, cy],
+ [cx + rx, cy],
+ ],
+ width,
+ height,
+ zoom
+ );
+ } else if (this.shapeType === "ellipse") {
+ const { rx, ry, cx, cy } = this.coordinates;
+ this._updateEllipseShape(width, height, zoom);
+ // Draw markers for center, horizontal radius and vertical radius points.
+ this._drawMarkers(
+ [
+ [cx, cy],
+ [cx + rx, cy],
+ [cx, cy + ry],
+ ],
+ width,
+ height,
+ zoom
+ );
+ } else if (this.shapeType === "inset") {
+ const { top, left, right, bottom } = this.coordinates;
+ const centerX = (left + (100 - right)) / 2;
+ const centerY = (top + (100 - bottom)) / 2;
+ const markerCoords = [
+ [centerX, top],
+ [100 - right, centerY],
+ [centerX, 100 - bottom],
+ [left, centerY],
+ ];
+ this._updateInsetShape(width, height, zoom);
+ // Draw markers for each of the inset's sides.
+ this._drawMarkers(markerCoords, width, height, zoom);
+ }
+ }
+
+ /**
+ * Update the SVGs for transform mode to fit the new shape.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateTransformMode(width, height, zoom) {
+ const { nw, ne, sw, se, n, w, s, e, rotatePoint, center } =
+ this.transformedBoundingBox;
+ const boundingBox = this.getElement("bounding-box");
+ const path = `M${nw.join(" ")} L${ne.join(" ")} L${se.join(" ")} L${sw.join(
+ " "
+ )} Z`;
+ boundingBox.setAttribute("d", path);
+ boundingBox.removeAttribute("hidden");
+
+ const markerPoints = [center, nw, ne, se, sw];
+ if (this.shapeType === "polygon" || this.shapeType === "ellipse") {
+ markerPoints.push(n, s, w, e);
+ }
+
+ if (this.shapeType === "polygon") {
+ this._updatePolygonShape(width, height, zoom);
+ markerPoints.push(rotatePoint);
+ const rotateLine = `M ${center.join(" ")} L ${rotatePoint.join(" ")}`;
+ this.getElement("rotate-line").setAttribute("d", rotateLine);
+ } else if (this.shapeType === "circle" || this.shapeType === "ellipse") {
+ // Shape renders for "circle()" and "ellipse()" use the same SVG nodes.
+ this._updateEllipseShape(width, height, zoom);
+ } else if (this.shapeType === "inset") {
+ this._updateInsetShape(width, height, zoom);
+ }
+
+ this._drawMarkers(markerPoints, width, height, zoom);
+ }
+
+ /**
+ * Update the SVG polygon to fit the CSS polygon.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updatePolygonShape(width, height, zoom) {
+ // Draw and show the polygon.
+ const points = this.coordinates.map(point => point.join(",")).join(" ");
+
+ const polygonEl = this.getElement("polygon");
+ polygonEl.setAttribute("points", points);
+ polygonEl.removeAttribute("hidden");
+
+ const clipPolygon = this.getElement("clip-polygon");
+ clipPolygon.setAttribute("points", points);
+ clipPolygon.removeAttribute("hidden");
+
+ const dashedPolygon = this.getElement("dashed-polygon");
+ dashedPolygon.setAttribute("points", points);
+ dashedPolygon.removeAttribute("hidden");
+ }
+
+ /**
+ * Update the SVG ellipse to fit the CSS circle or ellipse.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateEllipseShape(width, height, zoom) {
+ const { rx, ry, cx, cy } = this.coordinates;
+ const ellipseEl = this.getElement("ellipse");
+ ellipseEl.setAttribute("rx", rx);
+ ellipseEl.setAttribute("ry", ry);
+ ellipseEl.setAttribute("cx", cx);
+ ellipseEl.setAttribute("cy", cy);
+ ellipseEl.removeAttribute("hidden");
+
+ const clipEllipse = this.getElement("clip-ellipse");
+ clipEllipse.setAttribute("rx", rx);
+ clipEllipse.setAttribute("ry", ry);
+ clipEllipse.setAttribute("cx", cx);
+ clipEllipse.setAttribute("cy", cy);
+ clipEllipse.removeAttribute("hidden");
+
+ const dashedEllipse = this.getElement("dashed-ellipse");
+ dashedEllipse.setAttribute("rx", rx);
+ dashedEllipse.setAttribute("ry", ry);
+ dashedEllipse.setAttribute("cx", cx);
+ dashedEllipse.setAttribute("cy", cy);
+ dashedEllipse.removeAttribute("hidden");
+ }
+
+ /**
+ * Update the SVG rect to fit the CSS inset.
+ * @param {Number} width the width of the element quads
+ * @param {Number} height the height of the element quads
+ * @param {Number} zoom the zoom level of the window
+ */
+ _updateInsetShape(width, height, zoom) {
+ const { top, left, right, bottom } = this.coordinates;
+ const rectEl = this.getElement("rect");
+ rectEl.setAttribute("x", left);
+ rectEl.setAttribute("y", top);
+ rectEl.setAttribute("width", 100 - left - right);
+ rectEl.setAttribute("height", 100 - top - bottom);
+ rectEl.removeAttribute("hidden");
+
+ const clipRect = this.getElement("clip-rect");
+ clipRect.setAttribute("x", left);
+ clipRect.setAttribute("y", top);
+ clipRect.setAttribute("width", 100 - left - right);
+ clipRect.setAttribute("height", 100 - top - bottom);
+ clipRect.removeAttribute("hidden");
+
+ const dashedRect = this.getElement("dashed-rect");
+ dashedRect.setAttribute("x", left);
+ dashedRect.setAttribute("y", top);
+ dashedRect.setAttribute("width", 100 - left - right);
+ dashedRect.setAttribute("height", 100 - top - bottom);
+ dashedRect.removeAttribute("hidden");
+ }
+
+ /**
+ * Draw markers for the given coordinates.
+ * @param {Array} coords an array of coordinate arrays, of form [[x, y] ...]
+ * @param {Number} width the width of the element markers are being drawn for
+ * @param {Number} height the height of the element markers are being drawn for
+ * @param {Number} zoom the zoom level of the window
+ */
+ _drawMarkers(coords, width, height, zoom) {
+ const markers = coords
+ .map(([x, y]) => {
+ return getCirclePath(BASE_MARKER_SIZE, x, y, width, height, zoom);
+ })
+ .join(" ");
+ const outline = coords
+ .map(([x, y]) => {
+ return getCirclePath(BASE_MARKER_SIZE + 2, x, y, width, height, zoom);
+ })
+ .join(" ");
+
+ this.getElement("markers").setAttribute("d", markers);
+ this.getElement("markers-outline").setAttribute("d", outline);
+ }
+
+ /**
+ * Calculate the bounding box of the shape after it is transformed according to
+ * the transformation matrix.
+ * @returns {Object} of form { nw, ne, sw, se, n, s, w, e, rotatePoint, center }.
+ * Each element in the object is an array of form [x,y], denoting the x/y
+ * coordinates of the given point.
+ */
+ calculateTransformedBoundingBox() {
+ const { minX, minY, maxX, maxY } = this.origBoundingBox;
+ const { width, height } = this.currentDimensions;
+ const toPixel = scale(width / 100, height / 100);
+ const toPercent = scale(100 / width, 100 / height);
+ const matrix = multiply(toPercent, multiply(this.transformMatrix, toPixel));
+ const centerX = (minX + maxX) / 2;
+ const centerY = (minY + maxY) / 2;
+ const nw = apply(matrix, [minX, minY]);
+ const ne = apply(matrix, [maxX, minY]);
+ const sw = apply(matrix, [minX, maxY]);
+ const se = apply(matrix, [maxX, maxY]);
+ const n = apply(matrix, [centerX, minY]);
+ const s = apply(matrix, [centerX, maxY]);
+ const w = apply(matrix, [minX, centerY]);
+ const e = apply(matrix, [maxX, centerY]);
+ const center = apply(matrix, [centerX, centerY]);
+
+ const u = [
+ ((ne[0] - nw[0]) / 100) * width,
+ ((ne[1] - nw[1]) / 100) * height,
+ ];
+ const v = [
+ ((sw[0] - nw[0]) / 100) * width,
+ ((sw[1] - nw[1]) / 100) * height,
+ ];
+ const { basis, invertedBasis } = getBasis(u, v);
+ let rotatePointMatrix = changeMatrixBase(
+ translate(0, -ROTATE_LINE_LENGTH),
+ invertedBasis,
+ basis
+ );
+ rotatePointMatrix = multiply(
+ toPercent,
+ multiply(rotatePointMatrix, multiply(this.transformMatrix, toPixel))
+ );
+ const rotatePoint = apply(rotatePointMatrix, [centerX, centerY]);
+ return { nw, ne, sw, se, n, s, w, e, rotatePoint, center };
+ }
+
+ /**
+ * Hide the highlighter, the outline and the infobar.
+ */
+ _hide() {
+ setIgnoreLayoutChanges(true);
+
+ this._hideShapes();
+ this.getElement("markers").setAttribute("d", "");
+ this.getElement("root").setAttribute("style", "");
+
+ setIgnoreLayoutChanges(
+ false,
+ this.highlighterEnv.window.document.documentElement
+ );
+ }
+
+ onPageHide({ target }) {
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ /**
+ * Get the rough direction of the point relative to the anchor.
+ * If the handle is roughly horizontal relative to the anchor, return "ew".
+ * If the handle is roughly vertical relative to the anchor, return "ns"
+ * If the handle is roughly above/right or below/left, return "nesw"
+ * If the handle is roughly above/left or below/right, return "nwse"
+ * @param {String} pointName the name of the point being hovered
+ * @param {String} anchor the name of the anchor point
+ * @returns {String} The rough direction of the point relative to the anchor
+ */
+ getRoughDirection(pointName, anchor) {
+ const scalePoint = pointName.split("-")[1];
+ const anchorPos = this.transformedBoundingBox[anchor];
+ const scalePos = this.transformedBoundingBox[scalePoint];
+ const { minX, minY, maxX, maxY } = this.boundingBox;
+ const width = maxX - minX;
+ const height = maxY - minY;
+ const dx = (scalePos[0] - anchorPos[0]) / width;
+ const dy = (scalePos[1] - anchorPos[1]) / height;
+ if (dx >= -0.33 && dx <= 0.33) {
+ return "ns";
+ } else if (dy >= -0.33 && dy <= 0.33) {
+ return "ew";
+ } else if ((dx > 0.33 && dy < -0.33) || (dx < -0.33 && dy > 0.33)) {
+ return "nesw";
+ }
+ return "nwse";
+ }
+
+ /**
+ * Given a unit type, get the ratio by which to multiply a pixel value in order to
+ * convert pixels to that unit.
+ *
+ * Percentage units (%) are relative to a size. This must be provided when requesting
+ * a ratio for converting from pixels to percentages.
+ *
+ * @param {String} unit
+ * One of: %, em, rem, vw, vh
+ * @param {Number} size
+ * Size to which percentage values are relative to.
+ * @return {Number}
+ */
+ getUnitToPixelRatio(unit, size) {
+ let ratio;
+ const windowHeight = this.currentNode.ownerGlobal.innerHeight;
+ const windowWidth = this.currentNode.ownerGlobal.innerWidth;
+ switch (unit) {
+ case "%":
+ ratio = 100 / size;
+ break;
+ case "em":
+ ratio = 1 / parseFloat(getComputedStyle(this.currentNode).fontSize);
+ break;
+ case "rem":
+ const root = this.currentNode.ownerDocument.documentElement;
+ ratio = 1 / parseFloat(getComputedStyle(root).fontSize);
+ break;
+ case "vw":
+ ratio = 100 / windowWidth;
+ break;
+ case "vh":
+ ratio = 100 / windowHeight;
+ break;
+ case "vmin":
+ ratio = 100 / Math.min(windowHeight, windowWidth);
+ break;
+ case "vmax":
+ ratio = 100 / Math.max(windowHeight, windowWidth);
+ break;
+ default:
+ // If unit is not recognized, peg ratio 1:1 to pixels.
+ ratio = 1;
+ }
+
+ return ratio;
+ }
+}
+
+/**
+ * Get the "raw" (i.e. non-computed) shape definition on the given node.
+ * @param {Node} node the node to analyze
+ * @param {String} property the CSS property for which a value should be retrieved.
+ * @returns {String} the value of the given CSS property on the given node.
+ */
+function getDefinedShapeProperties(node, property) {
+ let prop = "";
+ if (!node) {
+ return prop;
+ }
+
+ const cssRules = getCSSStyleRules(node);
+ for (let i = 0; i < cssRules.length; i++) {
+ const rule = cssRules[i];
+ const value = rule.style.getPropertyValue(property);
+ if (value && value !== "auto") {
+ prop = value;
+ }
+ }
+
+ if (node.style) {
+ const value = node.style.getPropertyValue(property);
+ if (value && value !== "auto") {
+ prop = value;
+ }
+ }
+
+ return prop.trim();
+}
+
+/**
+ * Split coordinate pairs separated by a space and return an array.
+ * @param {String} coords the coordinate pair, where each coord is separated by a space.
+ * @returns {Array} a 2 element array containing the coordinates.
+ */
+function splitCoords(coords) {
+ // All coordinate pairs are of the form "x y" where x and y are values or
+ // calc() expressions. calc() expressions have spaces around operators, so
+ // replace those spaces with \u00a0 (non-breaking space) so they will not be
+ // split later.
+ return coords
+ .trim()
+ .replace(/ [\+\-\*\/] /g, match => {
+ return `\u00a0${match.trim()}\u00a0`;
+ })
+ .split(" ");
+}
+exports.splitCoords = splitCoords;
+
+/**
+ * Convert a coordinate to a percentage value.
+ * @param {String} coord a single coordinate
+ * @param {Number} size the size of the element (width or height) that the percentages
+ * are relative to
+ * @returns {Number} the coordinate as a percentage value
+ */
+function coordToPercent(coord, size) {
+ if (coord.includes("%")) {
+ // Just remove the % sign, nothing else to do, we're in a viewBox that's 100%
+ // worth.
+ return parseFloat(coord.replace("%", ""));
+ } else if (coord.includes("px")) {
+ // Convert the px value to a % value.
+ const px = parseFloat(coord.replace("px", ""));
+ return (px * 100) / size;
+ }
+
+ // Unit-less value, so 0.
+ return 0;
+}
+exports.coordToPercent = coordToPercent;
+
+/**
+ * Evaluates a CSS calc() expression (only handles addition)
+ * @param {String} expression the arguments to the calc() function
+ * @param {Number} size the size of the element (width or height) that percentage values
+ * are relative to
+ * @returns {Number} the result of the expression as a percentage value
+ */
+function evalCalcExpression(expression, size) {
+ // the calc() values returned by getComputedStyle only have addition, as it
+ // computes calc() expressions as much as possible without resolving percentages,
+ // leaving only addition.
+ const values = expression.split("+").map(v => v.trim());
+
+ return values.reduce((prev, curr) => {
+ return prev + coordToPercent(curr, size);
+ }, 0);
+}
+exports.evalCalcExpression = evalCalcExpression;
+
+/**
+ * Converts a shape mode to the proper CSS property name.
+ * @param {String} mode the mode of the CSS shape
+ * @returns the equivalent CSS property name
+ */
+const shapeModeToCssPropertyName = mode => {
+ const property = mode.substring(3);
+ return property.substring(0, 1).toLowerCase() + property.substring(1);
+};
+exports.shapeModeToCssPropertyName = shapeModeToCssPropertyName;
+
+/**
+ * Get the SVG path definition for a circle with given attributes.
+ * @param {Number} size the radius of the circle in pixels
+ * @param {Number} cx the x coordinate of the centre of the circle
+ * @param {Number} cy the y coordinate of the centre of the circle
+ * @param {Number} width the width of the element the circle is being drawn for
+ * @param {Number} height the height of the element the circle is being drawn for
+ * @param {Number} zoom the zoom level of the window the circle is drawn in
+ * @returns {String} the definition of the circle in SVG path description format.
+ */
+const getCirclePath = (size, cx, cy, width, height, zoom) => {
+ // We use a viewBox of 100x100 for shape-container so it's easy to position things
+ // based on their percentage, but this makes it more difficult to create circles.
+ // Therefor, 100px is the base size of shape-container. In order to make the markers'
+ // size scale properly, we must adjust the radius based on zoom and the width/height of
+ // the element being highlighted, then calculate a radius for both x/y axes based
+ // on the aspect ratio of the element.
+ const radius = (size * (100 / Math.max(width, height))) / zoom;
+ const ratio = width / height;
+ const rx = ratio > 1 ? radius : radius / ratio;
+ const ry = ratio > 1 ? radius * ratio : radius;
+ // a circle is drawn as two arc lines, starting at the leftmost point of the circle.
+ return (
+ `M${cx - rx},${cy}a${rx},${ry} 0 1,0 ${rx * 2},0` +
+ `a${rx},${ry} 0 1,0 ${rx * -2},0`
+ );
+};
+exports.getCirclePath = getCirclePath;
+
+/**
+ * Calculates the object bounding box for a node given its stroke bounding box.
+ * @param {Number} top the y coord of the top edge of the stroke bounding box
+ * @param {Number} left the x coord of the left edge of the stroke bounding box
+ * @param {Number} width the width of the stroke bounding box
+ * @param {Number} height the height of the stroke bounding box
+ * @param {Object} node the node object
+ * @returns {Object} an object of the form { top, left, width, height }, which
+ * are the top/left/width/height of the object bounding box for the node.
+ */
+const getObjectBoundingBox = (top, left, width, height, node) => {
+ // See https://drafts.fxtf.org/css-masking-1/#stroke-bounding-box for details
+ // on this algorithm. Note that we intentionally do not check "stroke-linecap".
+ const strokeWidth = parseFloat(getComputedStyle(node).strokeWidth);
+ let delta = strokeWidth / 2;
+ const tagName = node.tagName;
+
+ if (
+ tagName !== "rect" &&
+ tagName !== "ellipse" &&
+ tagName !== "circle" &&
+ tagName !== "image"
+ ) {
+ if (getComputedStyle(node).strokeLinejoin === "miter") {
+ const miter = getComputedStyle(node).strokeMiterlimit;
+ if (miter < Math.SQRT2) {
+ delta *= Math.SQRT2;
+ } else {
+ delta *= miter;
+ }
+ } else {
+ delta *= Math.SQRT2;
+ }
+ }
+
+ return {
+ top: top + delta,
+ left: left + delta,
+ width: width - 2 * delta,
+ height: height - 2 * delta,
+ };
+};
+
+/**
+ * Get the unit (e.g. px, %, em) for the given point value.
+ * @param {any} point a point value for which a unit should be retrieved.
+ * @returns {String} the unit.
+ */
+const getUnit = point => {
+ // If the point has no unit, default to px.
+ if (isUnitless(point)) {
+ return "px";
+ }
+ const [unit] = point.match(/[^\d]+$/) || ["px"];
+ return unit;
+};
+exports.getUnit = getUnit;
+
+/**
+ * Check if the given point value has a unit.
+ * @param {any} point a point value.
+ * @returns {Boolean} whether the given value has a unit.
+ */
+const isUnitless = point => {
+ return (
+ !point ||
+ !point.match(/[^\d]+$/) ||
+ // If zero doesn't have a unit, its numeric and string forms should be equal.
+ (parseFloat(point) === 0 && parseFloat(point).toString() === point) ||
+ point.includes("(") ||
+ point === "center" ||
+ point === "closest-side" ||
+ point === "farthest-side"
+ );
+};
+
+/**
+ * Return the anchor corresponding to the given scale type.
+ * @param {String} type a scale type, of form "scale-[direction]"
+ * @returns {String} a string describing the anchor, one of the 8 cardinal directions.
+ */
+const getAnchorPoint = type => {
+ let anchor = type.split("-")[1];
+ if (anchor.includes("n")) {
+ anchor = anchor.replace("n", "s");
+ } else if (anchor.includes("s")) {
+ anchor = anchor.replace("s", "n");
+ }
+ if (anchor.includes("w")) {
+ anchor = anchor.replace("w", "e");
+ } else if (anchor.includes("e")) {
+ anchor = anchor.replace("e", "w");
+ }
+
+ if (anchor === "e" || anchor === "w") {
+ anchor = "n" + anchor;
+ } else if (anchor === "n" || anchor === "s") {
+ anchor = anchor + "w";
+ }
+
+ return anchor;
+};
+
+/**
+ * Get the decimal point precision for values depending on unit type.
+ * Only handle pixels and falsy values for now. Round them to the nearest integer value.
+ * All other unit types round to two decimal points.
+ *
+ * @param {String|undefined} unitType any one of the accepted CSS unit types for position.
+ * @return {Number} decimal precision when rounding a value
+ */
+function getDecimalPrecision(unitType) {
+ switch (unitType) {
+ case "px":
+ case "":
+ case undefined:
+ return 0;
+ default:
+ return 2;
+ }
+}
+exports.getDecimalPrecision = getDecimalPrecision;
+
+/**
+ * Round up a numeric value to a fixed number of decimals depending on CSS unit type.
+ * Used when generating output shape values when:
+ * - transforming shapes
+ * - inserting new points on a polygon.
+ *
+ * @param {Number} number
+ * Value to round up.
+ * @param {String} unitType
+ * CSS unit type, like "px", "%", "em", "vh", etc.
+ * @return {Number}
+ * Rounded value
+ */
+function round(number, unitType) {
+ return number.toFixed(getDecimalPrecision(unitType));
+}
+
+exports.ShapesHighlighter = ShapesHighlighter;
diff --git a/devtools/server/actors/highlighters/tabbing-order.js b/devtools/server/actors/highlighters/tabbing-order.js
new file mode 100644
index 0000000000..ab96d30fe6
--- /dev/null
+++ b/devtools/server/actors/highlighters/tabbing-order.js
@@ -0,0 +1,247 @@
+/* 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 lazy = {};
+loader.lazyGetter(
+ lazy,
+ "ContentDOMReference",
+ () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ContentDOMReference.sys.mjs",
+ {
+ // ContentDOMReference needs to be retrieved from the shared global
+ // since it is a shared singleton.
+ loadInDevToolsLoader: false,
+ }
+ ).ContentDOMReference
+);
+loader.lazyRequireGetter(
+ this,
+ ["isFrameWithChildTarget", "isWindowIncluded"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "NodeTabbingOrderHighlighter",
+ "resource://devtools/server/actors/highlighters/node-tabbing-order.js",
+ true
+);
+
+const DEFAULT_FOCUS_FLAGS = Services.focus.FLAG_NOSCROLL;
+
+/**
+ * The TabbingOrderHighlighter uses focus manager to traverse all focusable
+ * nodes on the page and then uses the NodeTabbingOrderHighlighter to highlight
+ * these nodes.
+ */
+class TabbingOrderHighlighter {
+ constructor(highlighterEnv) {
+ this.highlighterEnv = highlighterEnv;
+ this._highlighters = new Map();
+
+ this.onMutation = this.onMutation.bind(this);
+ this.onPageHide = this.onPageHide.bind(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+
+ this.highlighterEnv.on("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this.onPageHide);
+ }
+
+ /**
+ * Static getter that indicates that TabbingOrderHighlighter supports
+ * highlighting in XUL windows.
+ */
+ static get XULSupported() {
+ return true;
+ }
+
+ get win() {
+ return this.highlighterEnv.window;
+ }
+
+ get focusedElement() {
+ return Services.focus.getFocusedElementForWindow(this.win, true, {});
+ }
+
+ set focusedElement(element) {
+ Services.focus.setFocus(element, DEFAULT_FOCUS_FLAGS);
+ }
+
+ moveFocus(startElement) {
+ return Services.focus.moveFocus(
+ this.win,
+ startElement.nodeType === Node.DOCUMENT_NODE
+ ? startElement.documentElement
+ : startElement,
+ Services.focus.MOVEFOCUS_FORWARD,
+ DEFAULT_FOCUS_FLAGS
+ );
+ }
+
+ /**
+ * Show NodeTabbingOrderHighlighter on each node that belongs to the keyboard
+ * tabbing order.
+ *
+ * @param {DOMNode} startElm
+ * Starting element to calculate tabbing order from.
+ *
+ * @param {JSON} options
+ * - options.index
+ * Start index for the tabbing order. Starting index will be 0 at
+ * the start of the tabbing order highlighting; in remote frames
+ * starting index will, typically, be greater than 0 (unless there
+ * was nothing to focus in the top level content document prior to
+ * the remote frame).
+ */
+ async show(startElm, { index }) {
+ const focusableElements = [];
+ const originalFocusedElement = this.focusedElement;
+ let currentFocusedElement = this.moveFocus(startElm);
+ while (
+ currentFocusedElement &&
+ isWindowIncluded(this.win, currentFocusedElement.ownerGlobal)
+ ) {
+ focusableElements.push(currentFocusedElement);
+ currentFocusedElement = this.moveFocus(currentFocusedElement);
+ }
+
+ // Allow to flush pending notifications to ensure the PresShell and frames
+ // are updated.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ let endElm = this.focusedElement;
+ if (
+ currentFocusedElement &&
+ !isWindowIncluded(this.win, currentFocusedElement.ownerGlobal)
+ ) {
+ endElm = null;
+ }
+
+ if (
+ !endElm &&
+ !!focusableElements.length &&
+ isFrameWithChildTarget(
+ this.highlighterEnv.targetActor,
+ focusableElements[focusableElements.length - 1]
+ )
+ ) {
+ endElm = focusableElements[focusableElements.length - 1];
+ }
+
+ if (originalFocusedElement && originalFocusedElement !== endElm) {
+ this.focusedElement = originalFocusedElement;
+ }
+
+ const highlighters = [];
+ for (let i = 0; i < focusableElements.length; i++) {
+ highlighters.push(
+ this._accumulateHighlighter(focusableElements[i], index++)
+ );
+ }
+ await Promise.all(highlighters);
+
+ this._trackMutations();
+
+ return {
+ contentDOMReference: endElm && lazy.ContentDOMReference.get(endElm),
+ index,
+ };
+ }
+
+ async _accumulateHighlighter(node, index) {
+ const highlighter = new NodeTabbingOrderHighlighter(this.highlighterEnv);
+ await highlighter.isReady;
+
+ highlighter.show(node, { index: index + 1 });
+ this._highlighters.set(node, highlighter);
+ }
+
+ hide() {
+ this._untrackMutations();
+ for (const highlighter of this._highlighters.values()) {
+ highlighter.destroy();
+ }
+
+ this._highlighters.clear();
+ }
+
+ /**
+ * Track mutations in the top level document subtree so that the appropriate
+ * NodeTabbingOrderHighlighter infobar's could be updated to reflect the
+ * attribute mutations on relevant nodes.
+ */
+ _trackMutations() {
+ const { win } = this;
+ this.currentMutationObserver = new win.MutationObserver(this.onMutation);
+ this.currentMutationObserver.observe(win.document.documentElement, {
+ subtree: true,
+ attributes: true,
+ });
+ }
+
+ _untrackMutations() {
+ if (!this.currentMutationObserver) {
+ return;
+ }
+
+ this.currentMutationObserver.disconnect();
+ this.currentMutationObserver = null;
+ }
+
+ onMutation(mutationList) {
+ for (const { target } of mutationList) {
+ const highlighter = this._highlighters.get(target);
+ if (highlighter) {
+ highlighter.update();
+ }
+ }
+ }
+
+ /**
+ * Update NodeTabbingOrderHighlighter focus styling for a node that,
+ * potentially, belongs to the tabbing order.
+ * @param {Object} options
+ * Options specifying the node and its focused state.
+ */
+ updateFocus({ node, focused }) {
+ const highlighter = this._highlighters.get(node);
+ if (!highlighter) {
+ return;
+ }
+
+ highlighter.updateFocus(focused);
+ }
+
+ destroy() {
+ this.highlighterEnv.off("will-navigate", this.onWillNavigate);
+
+ const { pageListenerTarget } = this.highlighterEnv;
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this.onPageHide);
+ }
+
+ this.hide();
+ this.highlighterEnv = null;
+ }
+
+ onPageHide({ target }) {
+ // If a pagehide event is triggered for current window's highlighter, hide
+ // the highlighter.
+ if (target.defaultView === this.win) {
+ this.hide();
+ }
+ }
+
+ onWillNavigate({ isTopLevel }) {
+ if (isTopLevel) {
+ this.hide();
+ }
+ }
+}
+
+exports.TabbingOrderHighlighter = TabbingOrderHighlighter;
diff --git a/devtools/server/actors/highlighters/utils/accessibility.js b/devtools/server/actors/highlighters/utils/accessibility.js
new file mode 100644
index 0000000000..23c4210a65
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/accessibility.js
@@ -0,0 +1,774 @@
+/* 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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ getCurrentZoom,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ moveInfobar,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+const {
+ truncateString,
+} = require("resource://devtools/shared/inspector/utils.js");
+
+const STRINGS_URI = "devtools/shared/locales/accessibility.properties";
+loader.lazyRequireGetter(
+ this,
+ "LocalizationHelper",
+ "resource://devtools/shared/l10n.js",
+ true
+);
+DevToolsUtils.defineLazyGetter(
+ this,
+ "L10N",
+ () => new LocalizationHelper(STRINGS_URI)
+);
+
+const {
+ accessibility: {
+ AUDIT_TYPE,
+ ISSUE_TYPE: {
+ [AUDIT_TYPE.KEYBOARD]: {
+ FOCUSABLE_NO_SEMANTICS,
+ FOCUSABLE_POSITIVE_TABINDEX,
+ INTERACTIVE_NO_ACTION,
+ INTERACTIVE_NOT_FOCUSABLE,
+ MOUSE_INTERACTIVE_ONLY,
+ NO_FOCUS_VISIBLE,
+ },
+ [AUDIT_TYPE.TEXT_LABEL]: {
+ AREA_NO_NAME_FROM_ALT,
+ DIALOG_NO_NAME,
+ DOCUMENT_NO_TITLE,
+ EMBED_NO_NAME,
+ FIGURE_NO_NAME,
+ FORM_FIELDSET_NO_NAME,
+ FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ FORM_NO_NAME,
+ FORM_NO_VISIBLE_NAME,
+ FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ FRAME_NO_NAME,
+ HEADING_NO_CONTENT,
+ HEADING_NO_NAME,
+ IFRAME_NO_NAME_FROM_TITLE,
+ IMAGE_NO_NAME,
+ INTERACTIVE_NO_NAME,
+ MATHML_GLYPH_NO_NAME,
+ TOOLBAR_NO_NAME,
+ },
+ },
+ SCORES,
+ },
+} = require("resource://devtools/shared/constants.js");
+
+// Max string length for truncating accessible name values.
+const MAX_STRING_LENGTH = 50;
+
+/**
+ * The AccessibleInfobar is a class responsible for creating the markup for the
+ * accessible highlighter. It is also reponsible for updating content within the
+ * infobar such as role and name values.
+ */
+class Infobar {
+ constructor(highlighter) {
+ this.highlighter = highlighter;
+ this.audit = new Audit(this);
+ }
+
+ get markup() {
+ return this.highlighter.markup;
+ }
+
+ get document() {
+ return this.highlighter.win.document;
+ }
+
+ get bounds() {
+ return this.highlighter._bounds;
+ }
+
+ get options() {
+ return this.highlighter.options;
+ }
+
+ get prefix() {
+ return this.highlighter.ID_CLASS_PREFIX;
+ }
+
+ get win() {
+ return this.highlighter.win;
+ }
+
+ /**
+ * Move the Infobar to the right place in the highlighter.
+ *
+ * @param {Element} container
+ * Container of infobar.
+ */
+ _moveInfobar(container) {
+ // Position the infobar using accessible's bounds
+ const { left: x, top: y, bottom, width } = this.bounds;
+ const infobarBounds = { x, y, bottom, width };
+
+ moveInfobar(container, infobarBounds, this.win);
+ }
+
+ /**
+ * Build markup for infobar.
+ *
+ * @param {Element} root
+ * Root element to build infobar with.
+ */
+ buildMarkup(root) {
+ const container = this.markup.createNode({
+ parent: root,
+ attributes: {
+ class: "infobar-container",
+ id: "infobar-container",
+ "aria-hidden": "true",
+ hidden: "true",
+ },
+ prefix: this.prefix,
+ });
+
+ const infobar = this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "infobar",
+ id: "infobar",
+ },
+ prefix: this.prefix,
+ });
+
+ const infobarText = this.markup.createNode({
+ parent: infobar,
+ attributes: {
+ class: "infobar-text",
+ id: "infobar-text",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: infobarText,
+ attributes: {
+ class: "infobar-role",
+ id: "infobar-role",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: infobarText,
+ attributes: {
+ class: "infobar-name",
+ id: "infobar-name",
+ },
+ prefix: this.prefix,
+ });
+
+ this.audit.buildMarkup(infobarText);
+ }
+
+ /**
+ * Destroy the Infobar's highlighter.
+ */
+ destroy() {
+ this.highlighter = null;
+ this.audit.destroy();
+ this.audit = null;
+ }
+
+ /**
+ * Gets the element with the specified ID.
+ *
+ * @param {String} id
+ * Element ID.
+ * @return {Element} The element with specified ID.
+ */
+ getElement(id) {
+ return this.highlighter.getElement(id);
+ }
+
+ /**
+ * Gets the text content of element.
+ *
+ * @param {String} id
+ * Element ID to retrieve text content from.
+ * @return {String} The text content of the element.
+ */
+ getTextContent(id) {
+ const anonymousContent = this.markup.content;
+ return anonymousContent.root.getElementById(`${this.prefix}${id}`)
+ .textContent;
+ }
+
+ /**
+ * Hide the accessible infobar.
+ */
+ hide() {
+ const container = this.getElement("infobar-container");
+ container.setAttribute("hidden", "true");
+ }
+
+ /**
+ * Show the accessible infobar highlighter.
+ */
+ show() {
+ const container = this.getElement("infobar-container");
+
+ // Remove accessible's infobar "hidden" attribute. We do this first to get the
+ // computed styles of the infobar container.
+ container.removeAttribute("hidden");
+
+ // Update the infobar's position and content.
+ this.update(container);
+ }
+
+ /**
+ * Update content of the infobar.
+ */
+ update(container) {
+ const { audit, name, role } = this.options;
+
+ this.updateRole(role, this.getElement("infobar-role"));
+ this.updateName(name, this.getElement("infobar-name"));
+ this.audit.update(audit);
+
+ // Position the infobar.
+ this._moveInfobar(container);
+ }
+
+ /**
+ * Sets the text content of the specified element.
+ *
+ * @param {Element} el
+ * Element to set text content on.
+ * @param {String} text
+ * Text for content.
+ */
+ setTextContent(el, text) {
+ el.setTextContent(text);
+ }
+
+ /**
+ * Show the accessible's name message.
+ *
+ * @param {String} name
+ * Accessible's name value.
+ * @param {Element} el
+ * Element to set text content on.
+ */
+ updateName(name, el) {
+ const nameText = name ? `"${truncateString(name, MAX_STRING_LENGTH)}"` : "";
+ this.setTextContent(el, nameText);
+ }
+
+ /**
+ * Show the accessible's role.
+ *
+ * @param {String} role
+ * Accessible's role value.
+ * @param {Element} el
+ * Element to set text content on.
+ */
+ updateRole(role, el) {
+ this.setTextContent(el, role);
+ }
+}
+
+/**
+ * Audit component used within the accessible highlighter infobar. This component is
+ * responsible for rendering and updating its containing AuditReport components that
+ * display various audit information such as contrast ratio score.
+ */
+class Audit {
+ constructor(infobar) {
+ this.infobar = infobar;
+
+ // A list of audit reports to be shown on the fly when highlighting an accessible
+ // object.
+ this.reports = {
+ [AUDIT_TYPE.CONTRAST]: new ContrastRatio(this),
+ [AUDIT_TYPE.KEYBOARD]: new Keyboard(this),
+ [AUDIT_TYPE.TEXT_LABEL]: new TextLabel(this),
+ };
+ }
+
+ get prefix() {
+ return this.infobar.prefix;
+ }
+
+ get markup() {
+ return this.infobar.markup;
+ }
+
+ buildMarkup(root) {
+ const audit = this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "infobar-audit",
+ id: "infobar-audit",
+ },
+ prefix: this.prefix,
+ });
+
+ Object.values(this.reports).forEach(report => report.buildMarkup(audit));
+ }
+
+ update(audit = {}) {
+ const el = this.getElement("infobar-audit");
+ el.setAttribute("hidden", true);
+
+ let updated = false;
+ Object.values(this.reports).forEach(report => {
+ if (report.update(audit)) {
+ updated = true;
+ }
+ });
+
+ if (updated) {
+ el.removeAttribute("hidden");
+ }
+ }
+
+ getElement(id) {
+ return this.infobar.getElement(id);
+ }
+
+ setTextContent(el, text) {
+ return this.infobar.setTextContent(el, text);
+ }
+
+ destroy() {
+ this.infobar = null;
+ Object.values(this.reports).forEach(report => report.destroy());
+ this.reports = null;
+ }
+}
+
+/**
+ * A common interface between audit report components used to render accessibility audit
+ * information for the currently highlighted accessible object.
+ */
+class AuditReport {
+ constructor(audit) {
+ this.audit = audit;
+ }
+
+ get prefix() {
+ return this.audit.prefix;
+ }
+
+ get markup() {
+ return this.audit.markup;
+ }
+
+ getElement(id) {
+ return this.audit.getElement(id);
+ }
+
+ setTextContent(el, text) {
+ return this.audit.setTextContent(el, text);
+ }
+
+ destroy() {
+ this.audit = null;
+ }
+}
+
+/**
+ * Contrast ratio audit report that is used to display contrast ratio score as part of the
+ * inforbar,
+ */
+class ContrastRatio extends AuditReport {
+ buildMarkup(root) {
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio-label",
+ id: "contrast-ratio-label",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio-error",
+ id: "contrast-ratio-error",
+ },
+ prefix: this.prefix,
+ text: L10N.getStr("accessibility.contrast.ratio.error"),
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio",
+ id: "contrast-ratio-min",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio-separator",
+ id: "contrast-ratio-separator",
+ },
+ prefix: this.prefix,
+ });
+
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "contrast-ratio",
+ id: "contrast-ratio-max",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ _fillAndStyleContrastValue(el, { value, className, color, backgroundColor }) {
+ value = value.toFixed(2);
+ this.setTextContent(el, value);
+ el.classList.add(className);
+ el.setAttribute(
+ "style",
+ `--accessibility-highlighter-contrast-ratio-color: rgba(${color});` +
+ `--accessibility-highlighter-contrast-ratio-bg: rgba(${backgroundColor});`
+ );
+ el.removeAttribute("hidden");
+ }
+
+ /**
+ * Update contrast ratio score infobar markup.
+ * @param {Object}
+ * Audit report for a given highlighted accessible.
+ * @return {Boolean}
+ * True if the contrast ratio markup was updated correctly and infobar audit
+ * block should be visible.
+ */
+ update(audit) {
+ const els = {};
+ for (const key of ["label", "min", "max", "error", "separator"]) {
+ const el = (els[key] = this.getElement(`contrast-ratio-${key}`));
+ if (["min", "max"].includes(key)) {
+ Object.values(SCORES).forEach(className =>
+ el.classList.remove(className)
+ );
+ this.setTextContent(el, "");
+ }
+
+ el.setAttribute("hidden", true);
+ el.removeAttribute("style");
+ }
+
+ if (!audit) {
+ return false;
+ }
+
+ const contrastRatio = audit[AUDIT_TYPE.CONTRAST];
+ if (!contrastRatio) {
+ return false;
+ }
+
+ const { isLargeText, error } = contrastRatio;
+ this.setTextContent(
+ els.label,
+ L10N.getStr(
+ `accessibility.contrast.ratio.label${isLargeText ? ".large" : ""}`
+ )
+ );
+ els.label.removeAttribute("hidden");
+ if (error) {
+ els.error.removeAttribute("hidden");
+ return true;
+ }
+
+ if (contrastRatio.value) {
+ const { value, color, score, backgroundColor } = contrastRatio;
+ this._fillAndStyleContrastValue(els.min, {
+ value,
+ className: score,
+ color,
+ backgroundColor,
+ });
+ return true;
+ }
+
+ const {
+ min,
+ max,
+ color,
+ backgroundColorMin,
+ backgroundColorMax,
+ scoreMin,
+ scoreMax,
+ } = contrastRatio;
+ this._fillAndStyleContrastValue(els.min, {
+ value: min,
+ className: scoreMin,
+ color,
+ backgroundColor: backgroundColorMin,
+ });
+ els.separator.removeAttribute("hidden");
+ this._fillAndStyleContrastValue(els.max, {
+ value: max,
+ className: scoreMax,
+ color,
+ backgroundColor: backgroundColorMax,
+ });
+
+ return true;
+ }
+}
+
+/**
+ * Keyboard audit report that is used to display a problem with keyboard
+ * accessibility as part of the inforbar.
+ */
+class Keyboard extends AuditReport {
+ /**
+ * A map from keyboard issues to annotation component properties.
+ */
+ static get ISSUE_TO_INFOBAR_LABEL_MAP() {
+ return {
+ [FOCUSABLE_NO_SEMANTICS]: "accessibility.keyboard.issue.semantics",
+ [FOCUSABLE_POSITIVE_TABINDEX]: "accessibility.keyboard.issue.tabindex",
+ [INTERACTIVE_NO_ACTION]: "accessibility.keyboard.issue.action",
+ [INTERACTIVE_NOT_FOCUSABLE]: "accessibility.keyboard.issue.focusable",
+ [MOUSE_INTERACTIVE_ONLY]: "accessibility.keyboard.issue.mouse.only",
+ [NO_FOCUS_VISIBLE]: "accessibility.keyboard.issue.focus.visible",
+ };
+ }
+
+ buildMarkup(root) {
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "audit",
+ id: "keyboard",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ /**
+ * Update keyboard audit infobar markup.
+ * @param {Object}
+ * Audit report for a given highlighted accessible.
+ * @return {Boolean}
+ * True if the keyboard markup was updated correctly and infobar audit
+ * block should be visible.
+ */
+ update(audit) {
+ const el = this.getElement("keyboard");
+ el.setAttribute("hidden", true);
+ Object.values(SCORES).forEach(className => el.classList.remove(className));
+
+ if (!audit) {
+ return false;
+ }
+
+ const keyboardAudit = audit[AUDIT_TYPE.KEYBOARD];
+ if (!keyboardAudit) {
+ return false;
+ }
+
+ const { issue, score } = keyboardAudit;
+ this.setTextContent(
+ el,
+ L10N.getStr(Keyboard.ISSUE_TO_INFOBAR_LABEL_MAP[issue])
+ );
+ el.classList.add(score);
+ el.removeAttribute("hidden");
+
+ return true;
+ }
+}
+
+/**
+ * Text label audit report that is used to display a problem with text alternatives
+ * as part of the inforbar.
+ */
+class TextLabel extends AuditReport {
+ /**
+ * A map from text label issues to annotation component properties.
+ */
+ static get ISSUE_TO_INFOBAR_LABEL_MAP() {
+ return {
+ [AREA_NO_NAME_FROM_ALT]: "accessibility.text.label.issue.area",
+ [DIALOG_NO_NAME]: "accessibility.text.label.issue.dialog",
+ [DOCUMENT_NO_TITLE]: "accessibility.text.label.issue.document.title",
+ [EMBED_NO_NAME]: "accessibility.text.label.issue.embed",
+ [FIGURE_NO_NAME]: "accessibility.text.label.issue.figure",
+ [FORM_FIELDSET_NO_NAME]: "accessibility.text.label.issue.fieldset",
+ [FORM_FIELDSET_NO_NAME_FROM_LEGEND]:
+ "accessibility.text.label.issue.fieldset.legend2",
+ [FORM_NO_NAME]: "accessibility.text.label.issue.form",
+ [FORM_NO_VISIBLE_NAME]: "accessibility.text.label.issue.form.visible",
+ [FORM_OPTGROUP_NO_NAME_FROM_LABEL]:
+ "accessibility.text.label.issue.optgroup.label2",
+ [FRAME_NO_NAME]: "accessibility.text.label.issue.frame",
+ [HEADING_NO_CONTENT]: "accessibility.text.label.issue.heading.content",
+ [HEADING_NO_NAME]: "accessibility.text.label.issue.heading",
+ [IFRAME_NO_NAME_FROM_TITLE]: "accessibility.text.label.issue.iframe",
+ [IMAGE_NO_NAME]: "accessibility.text.label.issue.image",
+ [INTERACTIVE_NO_NAME]: "accessibility.text.label.issue.interactive",
+ [MATHML_GLYPH_NO_NAME]: "accessibility.text.label.issue.glyph",
+ [TOOLBAR_NO_NAME]: "accessibility.text.label.issue.toolbar",
+ };
+ }
+
+ buildMarkup(root) {
+ this.markup.createNode({
+ nodeType: "span",
+ parent: root,
+ attributes: {
+ class: "audit",
+ id: "text-label",
+ },
+ prefix: this.prefix,
+ });
+ }
+
+ /**
+ * Update text label audit infobar markup.
+ * @param {Object}
+ * Audit report for a given highlighted accessible.
+ * @return {Boolean}
+ * True if the text label markup was updated correctly and infobar
+ * audit block should be visible.
+ */
+ update(audit) {
+ const el = this.getElement("text-label");
+ el.setAttribute("hidden", true);
+ Object.values(SCORES).forEach(className => el.classList.remove(className));
+
+ if (!audit) {
+ return false;
+ }
+
+ const textLabelAudit = audit[AUDIT_TYPE.TEXT_LABEL];
+ if (!textLabelAudit) {
+ return false;
+ }
+
+ const { issue, score } = textLabelAudit;
+ this.setTextContent(
+ el,
+ L10N.getStr(TextLabel.ISSUE_TO_INFOBAR_LABEL_MAP[issue])
+ );
+ el.classList.add(score);
+ el.removeAttribute("hidden");
+
+ return true;
+ }
+}
+
+/**
+ * A helper function that calculate accessible object bounds and positioning to
+ * be used for highlighting.
+ *
+ * @param {Object} win
+ * window that contains accessible object.
+ * @param {Object} options
+ * Object used for passing options:
+ * - {Number} x
+ * x coordinate of the top left corner of the accessible object
+ * - {Number} y
+ * y coordinate of the top left corner of the accessible object
+ * - {Number} w
+ * width of the the accessible object
+ * - {Number} h
+ * height of the the accessible object
+ * @return {Object|null} Returns, if available, positioning and bounds information for
+ * the accessible object.
+ */
+function getBounds(win, { x, y, w, h }) {
+ const { mozInnerScreenX, mozInnerScreenY, scrollX, scrollY } = win;
+ const zoom = getCurrentZoom(win);
+ let left = x;
+ let right = x + w;
+ let top = y;
+ let bottom = y + h;
+
+ left -= mozInnerScreenX - scrollX;
+ right -= mozInnerScreenX - scrollX;
+ top -= mozInnerScreenY - scrollY;
+ bottom -= mozInnerScreenY - scrollY;
+
+ left *= zoom;
+ right *= zoom;
+ top *= zoom;
+ bottom *= zoom;
+
+ const width = right - left;
+ const height = bottom - top;
+
+ return { left, right, top, bottom, width, height };
+}
+
+/**
+ * A helper function that calculate accessible object bounds and positioning to
+ * be used for highlighting in browser toolbox.
+ *
+ * @param {Object} win
+ * window that contains accessible object.
+ * @param {Object} options
+ * Object used for passing options:
+ * - {Number} x
+ * x coordinate of the top left corner of the accessible object
+ * - {Number} y
+ * y coordinate of the top left corner of the accessible object
+ * - {Number} w
+ * width of the the accessible object
+ * - {Number} h
+ * height of the the accessible object
+ * - {Number} zoom
+ * zoom level of the accessible object's parent window
+ * @return {Object|null} Returns, if available, positioning and bounds information for
+ * the accessible object.
+ */
+function getBoundsXUL(win, { x, y, w, h, zoom }) {
+ const { mozInnerScreenX, mozInnerScreenY } = win;
+ let left = x;
+ let right = x + w;
+ let top = y;
+ let bottom = y + h;
+
+ left *= zoom;
+ right *= zoom;
+ top *= zoom;
+ bottom *= zoom;
+
+ left -= mozInnerScreenX;
+ right -= mozInnerScreenX;
+ top -= mozInnerScreenY;
+ bottom -= mozInnerScreenY;
+
+ const width = right - left;
+ const height = bottom - top;
+
+ return { left, right, top, bottom, width, height };
+}
+
+exports.MAX_STRING_LENGTH = MAX_STRING_LENGTH;
+exports.getBounds = getBounds;
+exports.getBoundsXUL = getBoundsXUL;
+exports.Infobar = Infobar;
diff --git a/devtools/server/actors/highlighters/utils/canvas.js b/devtools/server/actors/highlighters/utils/canvas.js
new file mode 100644
index 0000000000..24285f02e0
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/canvas.js
@@ -0,0 +1,596 @@
+/* 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 {
+ apply,
+ getNodeTransformationMatrix,
+ getWritingModeMatrix,
+ identity,
+ isIdentity,
+ multiply,
+ scale,
+ translate,
+} = require("resource://devtools/shared/layout/dom-matrix-2d.js");
+const {
+ getCurrentZoom,
+ getViewportDimensions,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ getComputedStyle,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+// A set of utility functions for highlighters that render their content to a <canvas>
+// element.
+
+// We create a <canvas> element that has always 4096x4096 physical pixels, to displays
+// our grid's overlay.
+// Then, we move the element around when needed, to give the perception that it always
+// covers the screen (See bug 1345434).
+//
+// This canvas size value is the safest we can use because most GPUs can handle it.
+// It's also far from the maximum canvas memory allocation limit (4096x4096x4 is
+// 67.108.864 bytes, where the limit is 500.000.000 bytes, see
+// gfx_max_alloc_size in modules/libpref/init/StaticPrefList.yaml.
+//
+// Note:
+// Once bug 1232491 lands, we could try to refactor this code to use the values from
+// the displayport API instead.
+//
+// Using a fixed value should also solve bug 1348293.
+const CANVAS_SIZE = 4096;
+
+// The default color used for the canvas' font, fill and stroke colors.
+const DEFAULT_COLOR = "#9400FF";
+
+/**
+ * Draws a rect to the context given and applies a transformation matrix if passed.
+ * The coordinates are the start and end points of the rectangle's diagonal.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x1
+ * The x-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} y1
+ * The y-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} x2
+ * The x-axis coordinate of the rectangle's diagonal end point.
+ * @param {Number} y2
+ * The y-axis coordinate of the rectangle's diagonal end point.
+ * @param {Array} [matrix=identity()]
+ * The transformation matrix to apply.
+ */
+function clearRect(ctx, x1, y1, x2, y2, matrix = identity()) {
+ const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix);
+
+ // We are creating a clipping path and want it removed after we clear it's
+ // contents so we need to save the context.
+ ctx.save();
+
+ // Create a path to be cleared.
+ ctx.beginPath();
+ ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y));
+ ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y));
+ ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y));
+ ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y));
+ ctx.closePath();
+
+ // Restrict future drawing to the inside of the path.
+ ctx.clip();
+
+ // Clear any transforms applied to the canvas so that clearRect() really does
+ // clear everything.
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+
+ // Clear the contents of our clipped path by attempting to clear the canvas.
+ ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
+
+ // Restore the context to the state it was before changing transforms and
+ // adding clipping paths.
+ ctx.restore();
+}
+
+/**
+ * Draws an arrow-bubble rectangle in the provided canvas context.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x
+ * The x-axis origin of the rectangle.
+ * @param {Number} y
+ * The y-axis origin of the rectangle.
+ * @param {Number} width
+ * The width of the rectangle.
+ * @param {Number} height
+ * The height of the rectangle.
+ * @param {Number} radius
+ * The radius of the rounding.
+ * @param {Number} margin
+ * The distance of the origin point from the pointer.
+ * @param {Number} arrowSize
+ * The size of the arrow.
+ * @param {String} alignment
+ * The alignment of the rectangle in relation to its position to the grid.
+ */
+function drawBubbleRect(
+ ctx,
+ x,
+ y,
+ width,
+ height,
+ radius,
+ margin,
+ arrowSize,
+ alignment
+) {
+ let angle = 0;
+
+ if (alignment === "bottom") {
+ angle = 180;
+ } else if (alignment === "right") {
+ angle = 90;
+ [width, height] = [height, width];
+ } else if (alignment === "left") {
+ [width, height] = [height, width];
+ angle = 270;
+ }
+
+ const originX = x;
+ const originY = y;
+
+ ctx.save();
+ ctx.translate(originX, originY);
+ ctx.rotate(angle * (Math.PI / 180));
+ ctx.translate(-originX, -originY);
+ ctx.translate(-width / 2, -height - arrowSize - margin);
+
+ // The contour of the bubble is drawn with a path. The canvas context will have taken
+ // care of transforming the coordinates before calling the function, so we just always
+ // draw with the arrow pointing down. The top edge has rounded corners too.
+ ctx.beginPath();
+ // Start at the top/left corner (below the rounded corner).
+ ctx.moveTo(x, y + radius);
+ // Go down.
+ ctx.lineTo(x, y + height);
+ // Go down and the right, to draw the first half of the arrow tip.
+ ctx.lineTo(x + width / 2, y + height + arrowSize);
+ // Go back up and to the right, to draw the second half of the arrow tip.
+ ctx.lineTo(x + width, y + height);
+ // Go up to just below the top/right rounded corner.
+ ctx.lineTo(x + width, y + radius);
+ // Draw the top/right rounded corner.
+ ctx.arcTo(x + width, y, x + width - radius, y, radius);
+ // Go to the left.
+ ctx.lineTo(x + radius, y);
+ // Draw the top/left rounded corner.
+ ctx.arcTo(x, y, x, y + radius, radius);
+
+ ctx.stroke();
+ ctx.fill();
+
+ ctx.restore();
+}
+
+/**
+ * Draws a line to the context given and applies a transformation matrix if passed.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x1
+ * The x-axis of the coordinate for the begin of the line.
+ * @param {Number} y1
+ * The y-axis of the coordinate for the begin of the line.
+ * @param {Number} x2
+ * The x-axis of the coordinate for the end of the line.
+ * @param {Number} y2
+ * The y-axis of the coordinate for the end of the line.
+ * @param {Object} [options]
+ * The options object.
+ * @param {Array} [options.matrix=identity()]
+ * The transformation matrix to apply.
+ * @param {Array} [options.extendToBoundaries]
+ * If set, the line will be extended to reach the boundaries specified.
+ */
+function drawLine(ctx, x1, y1, x2, y2, options) {
+ const matrix = options.matrix || identity();
+
+ const p1 = apply(matrix, [x1, y1]);
+ const p2 = apply(matrix, [x2, y2]);
+
+ x1 = p1[0];
+ y1 = p1[1];
+ x2 = p2[0];
+ y2 = p2[1];
+
+ if (options.extendToBoundaries) {
+ if (p1[1] === p2[1]) {
+ x1 = options.extendToBoundaries[0];
+ x2 = options.extendToBoundaries[2];
+ } else {
+ y1 = options.extendToBoundaries[1];
+ x1 = ((p2[0] - p1[0]) * (y1 - p1[1])) / (p2[1] - p1[1]) + p1[0];
+ y2 = options.extendToBoundaries[3];
+ x2 = ((p2[0] - p1[0]) * (y2 - p1[1])) / (p2[1] - p1[1]) + p1[0];
+ }
+ }
+
+ ctx.beginPath();
+ ctx.moveTo(Math.round(x1), Math.round(y1));
+ ctx.lineTo(Math.round(x2), Math.round(y2));
+}
+
+/**
+ * Draws a rect to the context given and applies a transformation matrix if passed.
+ * The coordinates are the start and end points of the rectangle's diagonal.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x1
+ * The x-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} y1
+ * The y-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} x2
+ * The x-axis coordinate of the rectangle's diagonal end point.
+ * @param {Number} y2
+ * The y-axis coordinate of the rectangle's diagonal end point.
+ * @param {Array} [matrix=identity()]
+ * The transformation matrix to apply.
+ */
+function drawRect(ctx, x1, y1, x2, y2, matrix = identity()) {
+ const p = getPointsFromDiagonal(x1, y1, x2, y2, matrix);
+
+ ctx.beginPath();
+ ctx.moveTo(Math.round(p[0].x), Math.round(p[0].y));
+ ctx.lineTo(Math.round(p[1].x), Math.round(p[1].y));
+ ctx.lineTo(Math.round(p[2].x), Math.round(p[2].y));
+ ctx.lineTo(Math.round(p[3].x), Math.round(p[3].y));
+ ctx.closePath();
+}
+
+/**
+ * Draws a rounded rectangle in the provided canvas context.
+ *
+ * @param {CanvasRenderingContext2D} ctx
+ * The 2D canvas context.
+ * @param {Number} x
+ * The x-axis origin of the rectangle.
+ * @param {Number} y
+ * The y-axis origin of the rectangle.
+ * @param {Number} width
+ * The width of the rectangle.
+ * @param {Number} height
+ * The height of the rectangle.
+ * @param {Number} radius
+ * The radius of the rounding.
+ */
+function drawRoundedRect(ctx, x, y, width, height, radius) {
+ ctx.beginPath();
+ ctx.moveTo(x, y + radius);
+ ctx.lineTo(x, y + height - radius);
+ ctx.arcTo(x, y + height, x + radius, y + height, radius);
+ ctx.lineTo(x + width - radius, y + height);
+ ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius);
+ ctx.lineTo(x + width, y + radius);
+ ctx.arcTo(x + width, y, x + width - radius, y, radius);
+ ctx.lineTo(x + radius, y);
+ ctx.arcTo(x, y, x, y + radius, radius);
+ ctx.stroke();
+ ctx.fill();
+}
+
+/**
+ * Given an array of four points and returns a DOMRect-like object representing the
+ * boundaries defined by the four points.
+ *
+ * @param {Array} points
+ * An array with 4 pointer objects {x, y} representing the box quads.
+ * @return {Object} DOMRect-like object of the 4 points.
+ */
+function getBoundsFromPoints(points) {
+ const bounds = {};
+
+ bounds.left = Math.min(points[0].x, points[1].x, points[2].x, points[3].x);
+ bounds.right = Math.max(points[0].x, points[1].x, points[2].x, points[3].x);
+ bounds.top = Math.min(points[0].y, points[1].y, points[2].y, points[3].y);
+ bounds.bottom = Math.max(points[0].y, points[1].y, points[2].y, points[3].y);
+
+ bounds.x = bounds.left;
+ bounds.y = bounds.top;
+ bounds.width = bounds.right - bounds.left;
+ bounds.height = bounds.bottom - bounds.top;
+
+ return bounds;
+}
+
+/**
+ * Returns the current matrices for both canvas drawing and SVG taking into account the
+ * following transformations, in this order:
+ * 1. The scale given by the display pixel ratio.
+ * 2. The translation to the top left corner of the element.
+ * 3. The scale given by the current zoom.
+ * 4. The translation given by the top and left padding of the element.
+ * 5. Any CSS transformation applied directly to the element (only 2D
+ * transformation; the 3D transformation are flattened, see `dom-matrix-2d` module
+ * for further details.)
+ * 6. Rotate, translate, and reflect as needed to match the writing mode and text
+ * direction of the element.
+ *
+ * The transformations of the element's ancestors are not currently computed (see
+ * bug 1355675).
+ *
+ * @param {Element} element
+ * The current element.
+ * @param {Window} window
+ * The window object.
+ * @param {Object} [options.ignoreWritingModeAndTextDirection=false]
+ * Avoid transforming the current matrix to match the text direction
+ * and writing mode.
+ * @return {Object} An object with the following properties:
+ * - {Array} currentMatrix
+ * The current matrix.
+ * - {Boolean} hasNodeTransformations
+ * true if the node has transformed and false otherwise.
+ */
+function getCurrentMatrix(
+ element,
+ window,
+ { ignoreWritingModeAndTextDirection } = {}
+) {
+ const computedStyle = getComputedStyle(element);
+
+ const paddingTop = parseFloat(computedStyle.paddingTop);
+ const paddingRight = parseFloat(computedStyle.paddingRight);
+ const paddingBottom = parseFloat(computedStyle.paddingBottom);
+ const paddingLeft = parseFloat(computedStyle.paddingLeft);
+ const borderTop = parseFloat(computedStyle.borderTopWidth);
+ const borderRight = parseFloat(computedStyle.borderRightWidth);
+ const borderBottom = parseFloat(computedStyle.borderBottomWidth);
+ const borderLeft = parseFloat(computedStyle.borderLeftWidth);
+
+ const nodeMatrix = getNodeTransformationMatrix(
+ element,
+ window.document.documentElement
+ );
+
+ let currentMatrix = identity();
+ let hasNodeTransformations = false;
+
+ // Scale based on the device pixel ratio.
+ currentMatrix = multiply(currentMatrix, scale(window.devicePixelRatio));
+
+ // Apply the current node's transformation matrix, relative to the inspected window's
+ // root element, but only if it's not a identity matrix.
+ if (isIdentity(nodeMatrix)) {
+ hasNodeTransformations = false;
+ } else {
+ currentMatrix = multiply(currentMatrix, nodeMatrix);
+ hasNodeTransformations = true;
+ }
+
+ // Translate the origin based on the node's padding and border values.
+ currentMatrix = multiply(
+ currentMatrix,
+ translate(paddingLeft + borderLeft, paddingTop + borderTop)
+ );
+
+ // Adjust as needed to match the writing mode and text direction of the element.
+ const size = {
+ width:
+ element.offsetWidth -
+ borderLeft -
+ borderRight -
+ paddingLeft -
+ paddingRight,
+ height:
+ element.offsetHeight -
+ borderTop -
+ borderBottom -
+ paddingTop -
+ paddingBottom,
+ };
+
+ if (!ignoreWritingModeAndTextDirection) {
+ const writingModeMatrix = getWritingModeMatrix(size, computedStyle);
+ if (!isIdentity(writingModeMatrix)) {
+ currentMatrix = multiply(currentMatrix, writingModeMatrix);
+ }
+ }
+
+ return { currentMatrix, hasNodeTransformations };
+}
+
+/**
+ * Given an array of four points, returns a string represent a path description.
+ *
+ * @param {Array} points
+ * An array with 4 pointer objects {x, y} representing the box quads.
+ * @return {String} a Path Description that can be used in svg's <path> element.
+ */
+function getPathDescriptionFromPoints(points) {
+ return (
+ "M" +
+ points[0].x +
+ "," +
+ points[0].y +
+ " " +
+ "L" +
+ points[1].x +
+ "," +
+ points[1].y +
+ " " +
+ "L" +
+ points[2].x +
+ "," +
+ points[2].y +
+ " " +
+ "L" +
+ points[3].x +
+ "," +
+ points[3].y
+ );
+}
+
+/**
+ * Given the rectangle's diagonal start and end coordinates, returns an array containing
+ * the four coordinates of a rectangle. If a matrix is provided, applies the matrix
+ * function to each of the coordinates' value.
+ *
+ * @param {Number} x1
+ * The x-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} y1
+ * The y-axis coordinate of the rectangle's diagonal start point.
+ * @param {Number} x2
+ * The x-axis coordinate of the rectangle's diagonal end point.
+ * @param {Number} y2
+ * The y-axis coordinate of the rectangle's diagonal end point.
+ * @param {Array} [matrix=identity()]
+ * A transformation matrix to apply.
+ * @return {Array} the four coordinate points of the given rectangle transformed by the
+ * matrix given.
+ */
+function getPointsFromDiagonal(x1, y1, x2, y2, matrix = identity()) {
+ return [
+ [x1, y1],
+ [x2, y1],
+ [x2, y2],
+ [x1, y2],
+ ].map(point => {
+ const transformedPoint = apply(matrix, point);
+
+ return { x: transformedPoint[0], y: transformedPoint[1] };
+ });
+}
+
+/**
+ * Updates the <canvas> element's style in accordance with the current window's
+ * device pixel ratio, and the position calculated in `getCanvasPosition`. It also
+ * clears the drawing context. This is called on canvas update after a scroll event where
+ * `getCanvasPosition` updates the new canvasPosition.
+ *
+ * @param {Canvas} canvas
+ * The <canvas> element.
+ * @param {Object} canvasPosition
+ * A pointer object {x, y} representing the <canvas> position to the top left
+ * corner of the page.
+ * @param {Number} devicePixelRatio
+ * The device pixel ratio.
+ * @param {Window} [options.zoomWindow]
+ * Optional window object used to calculate zoom (default = undefined).
+ */
+function updateCanvasElement(
+ canvas,
+ canvasPosition,
+ devicePixelRatio,
+ { zoomWindow } = {}
+) {
+ let { x, y } = canvasPosition;
+ const size = CANVAS_SIZE / devicePixelRatio;
+
+ if (zoomWindow) {
+ const zoom = getCurrentZoom(zoomWindow);
+ x *= zoom;
+ y *= zoom;
+ }
+
+ // Resize the canvas taking the dpr into account so as to have crisp lines, and
+ // translating it to give the perception that it always covers the viewport.
+ canvas.setAttribute(
+ "style",
+ `width: ${size}px; height: ${size}px; transform: translate(${x}px, ${y}px);`
+ );
+ canvas.getCanvasContext("2d").clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
+}
+
+/**
+ * Calculates and returns the <canvas>'s position in accordance with the page's scroll,
+ * document's size, canvas size, and viewport's size. This is called when a page's scroll
+ * is detected.
+ *
+ * @param {Object} canvasPosition
+ * A pointer object {x, y} representing the <canvas> position to the top left
+ * corner of the page.
+ * @param {Object} scrollPosition
+ * A pointer object {x, y} representing the window's pageXOffset and pageYOffset.
+ * @param {Window} window
+ * The window object.
+ * @param {Object} windowDimensions
+ * An object {width, height} representing the window's dimensions for the
+ * `window` given.
+ * @return {Boolean} true if the <canvas> position was updated and false otherwise.
+ */
+function updateCanvasPosition(
+ canvasPosition,
+ scrollPosition,
+ window,
+ windowDimensions
+) {
+ let { x: canvasX, y: canvasY } = canvasPosition;
+ const { x: scrollX, y: scrollY } = scrollPosition;
+ const cssCanvasSize = CANVAS_SIZE / window.devicePixelRatio;
+ const viewportSize = getViewportDimensions(window);
+ const { height, width } = windowDimensions;
+ const canvasWidth = cssCanvasSize;
+ const canvasHeight = cssCanvasSize;
+ let hasUpdated = false;
+
+ // Those values indicates the relative horizontal and vertical space the page can
+ // scroll before we have to reposition the <canvas>; they're 1/4 of the delta between
+ // the canvas' size and the viewport's size: that's because we want to consider both
+ // sides (top/bottom, left/right; so 1/2 for each side) and also we don't want to
+ // shown the edges of the canvas in case of fast scrolling (to avoid showing undraw
+ // areas, therefore another 1/2 here).
+ const bufferSizeX = (canvasWidth - viewportSize.width) >> 2;
+ const bufferSizeY = (canvasHeight - viewportSize.height) >> 2;
+
+ // Defines the boundaries for the canvas.
+ const leftBoundary = 0;
+ const rightBoundary = width - canvasWidth;
+ const topBoundary = 0;
+ const bottomBoundary = height - canvasHeight;
+
+ // Defines the thresholds that triggers the canvas' position to be updated.
+ const leftThreshold = scrollX - bufferSizeX;
+ const rightThreshold =
+ scrollX - canvasWidth + viewportSize.width + bufferSizeX;
+ const topThreshold = scrollY - bufferSizeY;
+ const bottomThreshold =
+ scrollY - canvasHeight + viewportSize.height + bufferSizeY;
+
+ if (canvasX < rightBoundary && canvasX < rightThreshold) {
+ canvasX = Math.min(leftThreshold, rightBoundary);
+ hasUpdated = true;
+ } else if (canvasX > leftBoundary && canvasX > leftThreshold) {
+ canvasX = Math.max(rightThreshold, leftBoundary);
+ hasUpdated = true;
+ }
+
+ if (canvasY < bottomBoundary && canvasY < bottomThreshold) {
+ canvasY = Math.min(topThreshold, bottomBoundary);
+ hasUpdated = true;
+ } else if (canvasY > topBoundary && canvasY > topThreshold) {
+ canvasY = Math.max(bottomThreshold, topBoundary);
+ hasUpdated = true;
+ }
+
+ // Update the canvas position with the calculated canvasX and canvasY positions.
+ canvasPosition.x = canvasX;
+ canvasPosition.y = canvasY;
+
+ return hasUpdated;
+}
+
+exports.CANVAS_SIZE = CANVAS_SIZE;
+exports.DEFAULT_COLOR = DEFAULT_COLOR;
+exports.clearRect = clearRect;
+exports.drawBubbleRect = drawBubbleRect;
+exports.drawLine = drawLine;
+exports.drawRect = drawRect;
+exports.drawRoundedRect = drawRoundedRect;
+exports.getBoundsFromPoints = getBoundsFromPoints;
+exports.getCurrentMatrix = getCurrentMatrix;
+exports.getPathDescriptionFromPoints = getPathDescriptionFromPoints;
+exports.getPointsFromDiagonal = getPointsFromDiagonal;
+exports.updateCanvasElement = updateCanvasElement;
+exports.updateCanvasPosition = updateCanvasPosition;
diff --git a/devtools/server/actors/highlighters/utils/markup.js b/devtools/server/actors/highlighters/utils/markup.js
new file mode 100644
index 0000000000..a550ca0076
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -0,0 +1,787 @@
+/* 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 {
+ getCurrentZoom,
+ getWindowDimensions,
+ getViewportDimensions,
+} = require("resource://devtools/shared/layout/utils.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+const lazyContainer = {};
+
+loader.lazyRequireGetter(
+ lazyContainer,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isDocumentReady",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+exports.getComputedStyle = node =>
+ lazyContainer.CssLogic.getComputedStyle(node);
+
+exports.getBindingElementAndPseudo = node =>
+ lazyContainer.CssLogic.getBindingElementAndPseudo(node);
+
+exports.hasPseudoClassLock = (...args) =>
+ InspectorUtils.hasPseudoClassLock(...args);
+
+exports.addPseudoClassLock = (...args) =>
+ InspectorUtils.addPseudoClassLock(...args);
+
+exports.removePseudoClassLock = (...args) =>
+ InspectorUtils.removePseudoClassLock(...args);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const STYLESHEET_URI =
+ "resource://devtools-highlighter-styles/highlighters.css";
+
+const _tokens = Symbol("classList/tokens");
+
+/**
+ * Shims the element's `classList` for anonymous content elements; used
+ * internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
+ */
+function ClassList(className) {
+ const trimmed = (className || "").trim();
+ this[_tokens] = trimmed ? trimmed.split(/\s+/) : [];
+}
+
+ClassList.prototype = {
+ item(index) {
+ return this[_tokens][index];
+ },
+ contains(token) {
+ return this[_tokens].includes(token);
+ },
+ add(token) {
+ if (!this.contains(token)) {
+ this[_tokens].push(token);
+ }
+ EventEmitter.emit(this, "update");
+ },
+ remove(token) {
+ const index = this[_tokens].indexOf(token);
+
+ if (index > -1) {
+ this[_tokens].splice(index, 1);
+ }
+ EventEmitter.emit(this, "update");
+ },
+ toggle(token, force) {
+ // If force parameter undefined retain the toggle behavior
+ if (force === undefined) {
+ if (this.contains(token)) {
+ this.remove(token);
+ } else {
+ this.add(token);
+ }
+ } else if (force) {
+ // If force is true, enforce token addition
+ this.add(token);
+ } else {
+ // If force is falsy value, enforce token removal
+ this.remove(token);
+ }
+ },
+ get length() {
+ return this[_tokens].length;
+ },
+ *[Symbol.iterator]() {
+ for (let i = 0; i < this.tokens.length; i++) {
+ yield this[_tokens][i];
+ }
+ },
+ toString() {
+ return this[_tokens].join(" ");
+ },
+};
+
+/**
+ * Is this content window a XUL window?
+ * @param {Window} window
+ * @return {Boolean}
+ */
+function isXUL(window) {
+ return window.document.documentElement.namespaceURI === XUL_NS;
+}
+exports.isXUL = isXUL;
+
+/**
+ * Returns true if a DOM node is "valid", where "valid" means that the node isn't a dead
+ * object wrapper, is still attached to a document, and is of a given type.
+ * @param {DOMNode} node
+ * @param {Number} nodeType Optional, defaults to ELEMENT_NODE
+ * @return {Boolean}
+ */
+function isNodeValid(node, nodeType = Node.ELEMENT_NODE) {
+ // Is it still alive?
+ if (!node || Cu.isDeadWrapper(node)) {
+ return false;
+ }
+
+ // Is it of the right type?
+ if (node.nodeType !== nodeType) {
+ return false;
+ }
+
+ // Is its document accessible?
+ const doc = node.nodeType === Node.DOCUMENT_NODE ? node : node.ownerDocument;
+ if (!doc || !doc.defaultView) {
+ return false;
+ }
+
+ // Is the node connected to the document?
+ if (!node.isConnected) {
+ return false;
+ }
+
+ return true;
+}
+exports.isNodeValid = isNodeValid;
+
+/**
+ * Every highlighters should insert their markup content into the document's
+ * canvasFrame anonymous content container (see dom/webidl/Document.webidl).
+ *
+ * Since this container gets cleared when the document navigates, highlighters
+ * should use this helper to have their markup content automatically re-inserted
+ * in the new document.
+ *
+ * Since the markup content is inserted in the canvasFrame using
+ * insertAnonymousContent, this means that it can be modified using the API
+ * described in AnonymousContent.webidl.
+ * To retrieve the AnonymousContent instance, use the content getter.
+ *
+ * @param {HighlighterEnv} highlighterEnv
+ * The environemnt which windows will be used to insert the node.
+ * @param {Function} nodeBuilder
+ * A function that, when executed, returns a DOM node to be inserted into
+ * the canvasFrame.
+ * @param {Object} options
+ * @param {Boolean} options.waitForDocumentToLoad
+ * Set to false to try to insert the anonymous content even if the document
+ * isn't loaded yet. Defaults to true.
+ */
+function CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ nodeBuilder,
+ { waitForDocumentToLoad = true } = {}
+) {
+ this.highlighterEnv = highlighterEnv;
+ this.nodeBuilder = nodeBuilder;
+ this.waitForDocumentToLoad = !!waitForDocumentToLoad;
+
+ this._onWindowReady = this._onWindowReady.bind(this);
+ this.highlighterEnv.on("window-ready", this._onWindowReady);
+
+ this.listeners = new Map();
+ this.elements = new Map();
+}
+
+CanvasFrameAnonymousContentHelper.prototype = {
+ initialize() {
+ // _insert will resolve this promise once the markup is displayed
+ const onInitialized = new Promise(resolve => {
+ this._initialized = resolve;
+ });
+ // Only try to create the highlighter when the document is loaded,
+ // otherwise, wait for the window-ready event to fire.
+ const doc = this.highlighterEnv.document;
+ if (
+ doc.documentElement &&
+ (!this.waitForDocumentToLoad ||
+ isDocumentReady(doc) ||
+ doc.readyState !== "uninitialized")
+ ) {
+ this._insert();
+ }
+
+ return onInitialized;
+ },
+
+ destroy() {
+ this._remove();
+
+ this.highlighterEnv.off("window-ready", this._onWindowReady);
+ this.highlighterEnv = this.nodeBuilder = this._content = null;
+ this.anonymousContentDocument = null;
+ this.anonymousContentWindow = null;
+ this.pageListenerTarget = null;
+
+ this._removeAllListeners();
+ this.elements.clear();
+ },
+
+ async _insert() {
+ if (this.waitForDocumentToLoad) {
+ await waitForContentLoaded(this.highlighterEnv.window);
+ }
+ if (!this.highlighterEnv) {
+ // CanvasFrameAnonymousContentHelper was already destroyed.
+ return;
+ }
+
+ // Highlighters are drawn inside the anonymous content of the
+ // highlighter environment document.
+ this.anonymousContentDocument = this.highlighterEnv.document;
+ this.anonymousContentWindow = this.highlighterEnv.window;
+ this.pageListenerTarget = this.highlighterEnv.pageListenerTarget;
+
+ // It was stated that hidden documents don't accept
+ // `insertAnonymousContent` calls yet. That doesn't seems the case anymore,
+ // at least on desktop. Therefore, removing the code that was dealing with
+ // that scenario, fixes when we're adding anonymous content in a tab that
+ // is not the active one (see bug 1260043 and bug 1260044)
+ try {
+ // If we didn't wait for the document to load, we want to force a layout update
+ // to ensure the anonymous content will be rendered (see Bug 1580394).
+ const forceSynchronousLayoutUpdate = !this.waitForDocumentToLoad;
+ this._content = this.anonymousContentDocument.insertAnonymousContent(
+ forceSynchronousLayoutUpdate
+ );
+ } catch (e) {
+ // If the `insertAnonymousContent` fails throwing a `NS_ERROR_UNEXPECTED`, it means
+ // we don't have access to a `CustomContentContainer` yet (see bug 1365075).
+ // At this point, it could only happen on document's interactive state, and we
+ // need to wait until the `complete` state before inserting the anonymous content
+ // again.
+ if (
+ e.result === Cr.NS_ERROR_UNEXPECTED &&
+ this.anonymousContentDocument.readyState === "interactive"
+ ) {
+ // The next state change will be "complete" since the current is "interactive"
+ await new Promise(resolve => {
+ this.anonymousContentDocument.addEventListener(
+ "readystatechange",
+ resolve,
+ { once: true }
+ );
+ });
+ this._content = this.anonymousContentDocument.insertAnonymousContent();
+ } else {
+ throw e;
+ }
+ }
+
+ // Use createElementNS to make sure this is an HTML element.
+ // Document.createElement's behavior is different between SVG and HTML
+ // documents, see bug 1850007.
+ const link = this.anonymousContentDocument.createElementNS(
+ XHTML_NS,
+ "link"
+ );
+ link.href = STYLESHEET_URI;
+ link.rel = "stylesheet";
+ this._content.root.appendChild(link);
+ this._content.root.appendChild(this.nodeBuilder());
+
+ this._initialized();
+ },
+
+ _remove() {
+ try {
+ this.anonymousContentDocument.removeAnonymousContent(this._content);
+ } catch (e) {
+ // If the current window isn't the one the content was inserted into, this
+ // will fail, but that's fine.
+ }
+ },
+
+ /**
+ * The "window-ready" event can be triggered when:
+ * - a new window is created
+ * - a window is unfrozen from bfcache
+ * - when first attaching to a page
+ * - when swapping frame loaders (moving tabs, toggling RDM)
+ */
+ _onWindowReady({ isTopLevel }) {
+ if (isTopLevel) {
+ this._removeAllListeners();
+ this.elements.clear();
+ this._insert();
+ }
+ },
+
+ _getNodeById(id) {
+ return this.content?.root.getElementById(id);
+ },
+
+ getBoundingClientRect(id) {
+ const node = this._getNodeById(id);
+ if (!node) {
+ return null;
+ }
+ return node.getBoundingClientRect();
+ },
+
+ getComputedStylePropertyValue(id, property) {
+ const node = this._getNodeById(id);
+ if (!node) {
+ return null;
+ }
+ return this.anonymousContentWindow
+ .getComputedStyle(node)
+ .getPropertyValue(property);
+ },
+
+ getTextContentForElement(id) {
+ return this._getNodeById(id)?.textContent;
+ },
+
+ setTextContentForElement(id, text) {
+ const node = this._getNodeById(id);
+ if (!node) {
+ return;
+ }
+ node.textContent = text;
+ },
+
+ setAttributeForElement(id, name, value) {
+ this._getNodeById(id)?.setAttribute(name, value);
+ },
+
+ getAttributeForElement(id, name) {
+ return this._getNodeById(id)?.getAttribute(name);
+ },
+
+ removeAttributeForElement(id, name) {
+ this._getNodeById(id)?.removeAttribute(name);
+ },
+
+ hasAttributeForElement(id, name) {
+ return typeof this.getAttributeForElement(id, name) === "string";
+ },
+
+ getCanvasContext(id, type = "2d") {
+ return this._getNodeById(id)?.getContext(type);
+ },
+
+ /**
+ * Add an event listener to one of the elements inserted in the canvasFrame
+ * native anonymous container.
+ * Like other methods in this helper, this requires the ID of the element to
+ * be passed in.
+ *
+ * Note that if the content page navigates, the event listeners won't be
+ * added again.
+ *
+ * Also note that unlike traditional DOM events, the events handled by
+ * listeners added here will propagate through the document only through
+ * bubbling phase, so the useCapture parameter isn't supported.
+ * It is possible however to call e.stopPropagation() to stop the bubbling.
+ *
+ * IMPORTANT: the chrome-only canvasFrame insertion API takes great care of
+ * not leaking references to inserted elements to chrome JS code. That's
+ * because otherwise, chrome JS code could freely modify native anon elements
+ * inside the canvasFrame and probably change things that are assumed not to
+ * change by the C++ code managing this frame.
+ * See https://wiki.mozilla.org/DevTools/Highlighter#The_AnonymousContent_API
+ * Unfortunately, the inserted nodes are still available via
+ * event.originalTarget, and that's what the event handler here uses to check
+ * that the event actually occured on the right element, but that also means
+ * consumers of this code would be able to access the inserted elements.
+ * Therefore, the originalTarget property will be nullified before the event
+ * is passed to your handler.
+ *
+ * IMPL DETAIL: A single event listener is added per event types only, at
+ * browser level and if the event originalTarget is found to have the provided
+ * ID, the callback is executed (and then IDs of parent nodes of the
+ * originalTarget are checked too).
+ *
+ * @param {String} id
+ * @param {String} type
+ * @param {Function} handler
+ */
+ addEventListenerForElement(id, type, handler) {
+ if (typeof id !== "string") {
+ throw new Error(
+ "Expected a string ID in addEventListenerForElement but" + " got: " + id
+ );
+ }
+
+ // If no one is listening for this type of event yet, add one listener.
+ if (!this.listeners.has(type)) {
+ const target = this.pageListenerTarget;
+ target.addEventListener(type, this, true);
+ // Each type entry in the map is a map of ids:handlers.
+ this.listeners.set(type, new Map());
+ }
+
+ const listeners = this.listeners.get(type);
+ listeners.set(id, handler);
+ },
+
+ /**
+ * Remove an event listener from one of the elements inserted in the
+ * canvasFrame native anonymous container.
+ * @param {String} id
+ * @param {String} type
+ */
+ removeEventListenerForElement(id, type) {
+ const listeners = this.listeners.get(type);
+ if (!listeners) {
+ return;
+ }
+ listeners.delete(id);
+
+ // If no one is listening for event type anymore, remove the listener.
+ if (!this.listeners.has(type)) {
+ const target = this.pageListenerTarget;
+ target.removeEventListener(type, this, true);
+ }
+ },
+
+ handleEvent(event) {
+ const listeners = this.listeners.get(event.type);
+ if (!listeners) {
+ return;
+ }
+
+ // Hide the originalTarget property to avoid exposing references to native
+ // anonymous elements. See addEventListenerForElement's comment.
+ let isPropagationStopped = false;
+ const eventProxy = new Proxy(event, {
+ get: (obj, name) => {
+ if (name === "originalTarget") {
+ return null;
+ } else if (name === "stopPropagation") {
+ return () => {
+ isPropagationStopped = true;
+ };
+ }
+ return obj[name];
+ },
+ });
+
+ // Start at originalTarget, bubble through ancestors and call handlers when
+ // needed.
+ let node = event.originalTarget;
+ while (node) {
+ const handler = listeners.get(node.id);
+ if (handler) {
+ handler(eventProxy, node.id);
+ if (isPropagationStopped) {
+ break;
+ }
+ }
+ node = node.parentNode;
+ }
+ },
+
+ _removeAllListeners() {
+ if (this.pageListenerTarget) {
+ const target = this.pageListenerTarget;
+ for (const [type] of this.listeners) {
+ target.removeEventListener(type, this, true);
+ }
+ }
+ this.listeners.clear();
+ },
+
+ getElement(id) {
+ if (this.elements.has(id)) {
+ return this.elements.get(id);
+ }
+
+ const classList = new ClassList(this.getAttributeForElement(id, "class"));
+
+ EventEmitter.on(classList, "update", () => {
+ this.setAttributeForElement(id, "class", classList.toString());
+ });
+
+ const element = {
+ getTextContent: () => this.getTextContentForElement(id),
+ setTextContent: text => this.setTextContentForElement(id, text),
+ setAttribute: (name, val) => this.setAttributeForElement(id, name, val),
+ getAttribute: name => this.getAttributeForElement(id, name),
+ removeAttribute: name => this.removeAttributeForElement(id, name),
+ hasAttribute: name => this.hasAttributeForElement(id, name),
+ getCanvasContext: type => this.getCanvasContext(id, type),
+ addEventListener: (type, handler) => {
+ return this.addEventListenerForElement(id, type, handler);
+ },
+ removeEventListener: (type, handler) => {
+ return this.removeEventListenerForElement(id, type, handler);
+ },
+ computedStyle: {
+ getPropertyValue: property =>
+ this.getComputedStylePropertyValue(id, property),
+ },
+ classList,
+ };
+
+ this.elements.set(id, element);
+
+ return element;
+ },
+
+ get content() {
+ if (!this._content || Cu.isDeadWrapper(this._content)) {
+ return null;
+ }
+ return this._content;
+ },
+
+ /**
+ * The canvasFrame anonymous content container gets zoomed in/out with the
+ * page. If this is unwanted, i.e. if you want the inserted element to remain
+ * unzoomed, then this method can be used.
+ *
+ * Consumers of the CanvasFrameAnonymousContentHelper should call this method,
+ * it isn't executed automatically. Typically, AutoRefreshHighlighter can call
+ * it when _update is executed.
+ *
+ * The matching element will be scaled down or up by 1/zoomLevel (using css
+ * transform) to cancel the current zoom. The element's width and height
+ * styles will also be set according to the scale. Finally, the element's
+ * position will be set as absolute.
+ *
+ * Note that if the matching element already has an inline style attribute, it
+ * *won't* be preserved.
+ *
+ * @param {DOMNode} node This node is used to determine which container window
+ * should be used to read the current zoom value.
+ * @param {String} id The ID of the root element inserted with this API.
+ */
+ scaleRootElement(node, id) {
+ const boundaryWindow = this.highlighterEnv.window;
+ const zoom = getCurrentZoom(node);
+ // Hide the root element and force the reflow in order to get the proper window's
+ // dimensions without increasing them.
+ const root = this._getNodeById(id);
+ root.style.display = "none";
+ node.offsetWidth;
+
+ let { width, height } = getWindowDimensions(boundaryWindow);
+ let value = "";
+
+ if (zoom !== 1) {
+ value = `transform-origin:top left; transform:scale(${1 / zoom}); `;
+ width *= zoom;
+ height *= zoom;
+ }
+
+ value += `position:absolute; width:${width}px;height:${height}px; overflow:hidden;`;
+ root.style = value;
+ },
+
+ /**
+ * Helper function that creates SVG DOM nodes.
+ * @param {Object} Options for the node include:
+ * - nodeType: the type of node, defaults to "box".
+ * - attributes: a {name:value} object to be used as attributes for the node.
+ * - prefix: a string that will be used to prefix the values of the id and class
+ * attributes.
+ * - parent: if provided, the newly created element will be appended to this
+ * node.
+ */
+ createSVGNode(options) {
+ if (!options.nodeType) {
+ options.nodeType = "box";
+ }
+
+ options.namespace = SVG_NS;
+
+ return this.createNode(options);
+ },
+
+ /**
+ * Helper function that creates DOM nodes.
+ * @param {Object} Options for the node include:
+ * - nodeType: the type of node, defaults to "div".
+ * - namespace: the namespace to use to create the node, defaults to XHTML namespace.
+ * - attributes: a {name:value} object to be used as attributes for the node.
+ * - prefix: a string that will be used to prefix the values of the id and class
+ * attributes.
+ * - parent: if provided, the newly created element will be appended to this
+ * node.
+ * - text: if provided, set the text content of the element.
+ */
+ createNode(options) {
+ const type = options.nodeType || "div";
+ const namespace = options.namespace || XHTML_NS;
+ const doc = this.anonymousContentDocument;
+
+ const node = doc.createElementNS(namespace, type);
+
+ for (const name in options.attributes || {}) {
+ let value = options.attributes[name];
+ if (options.prefix && (name === "class" || name === "id")) {
+ value = options.prefix + value;
+ }
+ node.setAttribute(name, value);
+ }
+
+ if (options.parent) {
+ options.parent.appendChild(node);
+ }
+
+ if (options.text) {
+ node.appendChild(doc.createTextNode(options.text));
+ }
+
+ return node;
+ },
+};
+exports.CanvasFrameAnonymousContentHelper = CanvasFrameAnonymousContentHelper;
+
+/**
+ * Wait for document readyness.
+ * @param {Object} iframeOrWindow
+ * IFrame or Window for which the content should be loaded.
+ */
+function waitForContentLoaded(iframeOrWindow) {
+ let loadEvent = "DOMContentLoaded";
+ // If we are waiting for an iframe to load and it is for a XUL window
+ // highlighter that is not browser toolbox, we must wait for IFRAME's "load".
+ if (
+ iframeOrWindow.contentWindow &&
+ iframeOrWindow.ownerGlobal !==
+ iframeOrWindow.contentWindow.browsingContext.topChromeWindow
+ ) {
+ loadEvent = "load";
+ }
+
+ const doc = iframeOrWindow.contentDocument || iframeOrWindow.document;
+ if (isDocumentReady(doc)) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ iframeOrWindow.addEventListener(loadEvent, resolve, { once: true });
+ });
+}
+
+/**
+ * Move the infobar to the right place in the highlighter. This helper method is utilized
+ * in both css-grid.js and box-model.js to help position the infobar in an appropriate
+ * space over the highlighted node element or grid area. The infobar is used to display
+ * relevant information about the highlighted item (ex, node or grid name and dimensions).
+ *
+ * This method will first try to position the infobar to top or bottom of the container
+ * such that it has enough space for the height of the infobar. Afterwards, it will try
+ * to horizontally center align with the container element if possible.
+ *
+ * @param {DOMNode} container
+ * The container element which will be used to position the infobar.
+ * @param {Object} bounds
+ * The content bounds of the container element.
+ * @param {Window} win
+ * The window object.
+ * @param {Object} [options={}]
+ * Advanced options for the infobar.
+ * @param {String} options.position
+ * Force the infobar to be displayed either on "top" or "bottom". Any other value
+ * will be ingnored.
+ */
+function moveInfobar(container, bounds, win, options = {}) {
+ const zoom = getCurrentZoom(win);
+ const viewport = getViewportDimensions(win);
+
+ const { computedStyle } = container;
+
+ const margin = 2;
+ const arrowSize = parseFloat(
+ computedStyle.getPropertyValue("--highlighter-bubble-arrow-size")
+ );
+ const containerHeight = parseFloat(computedStyle.getPropertyValue("height"));
+ const containerWidth = parseFloat(computedStyle.getPropertyValue("width"));
+ const containerHalfWidth = containerWidth / 2;
+
+ const viewportWidth = viewport.width * zoom;
+ const viewportHeight = viewport.height * zoom;
+ let { pageXOffset, pageYOffset } = win;
+
+ pageYOffset *= zoom;
+ pageXOffset *= zoom;
+
+ // Defines the boundaries for the infobar.
+ const topBoundary = margin;
+ const bottomBoundary = viewportHeight - containerHeight - margin - 1;
+ const leftBoundary = containerHalfWidth + margin;
+ const rightBoundary = viewportWidth - containerHalfWidth - margin;
+
+ // Set the default values.
+ let top = bounds.y - containerHeight - arrowSize;
+ const bottom = bounds.bottom + margin + arrowSize;
+ let left = bounds.x + bounds.width / 2;
+ let isOverlapTheNode = false;
+ let positionAttribute = "top";
+ let position = "absolute";
+
+ // Here we start the math.
+ // We basically want to position absolutely the infobar, except when is pointing to a
+ // node that is offscreen or partially offscreen, in a way that the infobar can't
+ // be placed neither on top nor on bottom.
+ // In such cases, the infobar will overlap the node, and to limit the latency given
+ // by APZ (See Bug 1312103) it will be positioned as "fixed".
+ // It's a sort of "position: sticky" (but positioned as absolute instead of relative).
+ const canBePlacedOnTop = top >= pageYOffset;
+ const canBePlacedOnBottom = bottomBoundary + pageYOffset - bottom > 0;
+ const forcedOnTop = options.position === "top";
+ const forcedOnBottom = options.position === "bottom";
+
+ if (
+ (!canBePlacedOnTop && canBePlacedOnBottom && !forcedOnTop) ||
+ forcedOnBottom
+ ) {
+ top = bottom;
+ positionAttribute = "bottom";
+ }
+
+ const isOffscreenOnTop = top < topBoundary + pageYOffset;
+ const isOffscreenOnBottom = top > bottomBoundary + pageYOffset;
+ const isOffscreenOnLeft = left < leftBoundary + pageXOffset;
+ const isOffscreenOnRight = left > rightBoundary + pageXOffset;
+
+ if (isOffscreenOnTop) {
+ top = topBoundary;
+ isOverlapTheNode = true;
+ } else if (isOffscreenOnBottom) {
+ top = bottomBoundary;
+ isOverlapTheNode = true;
+ } else if (isOffscreenOnLeft || isOffscreenOnRight) {
+ isOverlapTheNode = true;
+ top -= pageYOffset;
+ }
+
+ if (isOverlapTheNode) {
+ left = Math.min(Math.max(leftBoundary, left - pageXOffset), rightBoundary);
+
+ position = "fixed";
+ container.setAttribute("hide-arrow", "true");
+ } else {
+ position = "absolute";
+ container.removeAttribute("hide-arrow");
+ }
+
+ // We need to scale the infobar Independently from the highlighter's container;
+ // otherwise the `position: fixed` won't work, since "any value other than `none` for
+ // the transform, results in the creation of both a stacking context and a containing
+ // block. The object acts as a containing block for fixed positioned descendants."
+ // (See https://www.w3.org/TR/css-transforms-1/#transform-rendering)
+ // We also need to shift the infobar 50% to the left in order for it to appear centered
+ // on the element it points to.
+ container.setAttribute(
+ "style",
+ `
+ position:${position};
+ transform-origin: 0 0;
+ transform: scale(${1 / zoom}) translate(calc(${left}px - 50%), ${top}px)`
+ );
+
+ container.setAttribute("position", positionAttribute);
+}
+exports.moveInfobar = moveInfobar;
diff --git a/devtools/server/actors/highlighters/utils/moz.build b/devtools/server/actors/highlighters/utils/moz.build
new file mode 100644
index 0000000000..ab4f96912d
--- /dev/null
+++ b/devtools/server/actors/highlighters/utils/moz.build
@@ -0,0 +1,7 @@
+# -*- 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("accessibility.js", "canvas.js", "markup.js")
diff --git a/devtools/server/actors/highlighters/viewport-size.js b/devtools/server/actors/highlighters/viewport-size.js
new file mode 100644
index 0000000000..4c85a305ca
--- /dev/null
+++ b/devtools/server/actors/highlighters/viewport-size.js
@@ -0,0 +1,129 @@
+/* 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 {
+ setIgnoreLayoutChanges,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ CanvasFrameAnonymousContentHelper,
+} = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+
+/**
+ * The ViewportSizeHighlighter is a class that displays the viewport
+ * width and height on a small overlay on the top right edge of the page
+ * while the rulers are turned on.
+ */
+class ViewportSizeHighlighter {
+ constructor(highlighterEnv) {
+ this.env = highlighterEnv;
+ this.markup = new CanvasFrameAnonymousContentHelper(
+ highlighterEnv,
+ this._buildMarkup.bind(this)
+ );
+ this.isReady = this.markup.initialize();
+
+ const { pageListenerTarget } = highlighterEnv;
+ pageListenerTarget.addEventListener("pagehide", this);
+ }
+
+ ID_CLASS_PREFIX = "viewport-size-highlighter-";
+
+ _buildMarkup() {
+ const prefix = this.ID_CLASS_PREFIX;
+
+ const container = this.markup.createNode({
+ attributes: { class: "highlighter-container" },
+ });
+
+ this.markup.createNode({
+ parent: container,
+ attributes: {
+ class: "viewport-infobar-container",
+ id: "viewport-infobar-container",
+ position: "top",
+ },
+ prefix,
+ });
+
+ return container;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "pagehide":
+ // If a page hide event is triggered for current window's highlighter, hide the
+ // highlighter.
+ if (event.target.defaultView === this.env.window) {
+ this.destroy();
+ }
+ break;
+ }
+ }
+
+ _update() {
+ const { window } = this.env;
+
+ setIgnoreLayoutChanges(true);
+
+ this.updateViewportInfobar();
+
+ setIgnoreLayoutChanges(false, window.document.documentElement);
+
+ this._rafID = window.requestAnimationFrame(() => this._update());
+ }
+
+ _cancelUpdate() {
+ if (this._rafID) {
+ this.env.window.cancelAnimationFrame(this._rafID);
+ this._rafID = 0;
+ }
+ }
+
+ updateViewportInfobar() {
+ const { window } = this.env;
+ const { innerHeight, innerWidth } = window;
+ const infobarId = this.ID_CLASS_PREFIX + "viewport-infobar-container";
+ const textContent = innerWidth + "px \u00D7 " + innerHeight + "px";
+ this.markup.getElement(infobarId).setTextContent(textContent);
+ }
+
+ destroy() {
+ this.hide();
+
+ const { pageListenerTarget } = this.env;
+
+ if (pageListenerTarget) {
+ pageListenerTarget.removeEventListener("pagehide", this);
+ }
+
+ this.markup.destroy();
+
+ EventEmitter.emit(this, "destroy");
+ }
+
+ show() {
+ this.markup.removeAttributeForElement(
+ this.ID_CLASS_PREFIX + "viewport-infobar-container",
+ "hidden"
+ );
+
+ this._update();
+
+ return true;
+ }
+
+ hide() {
+ this.markup.setAttributeForElement(
+ this.ID_CLASS_PREFIX + "viewport-infobar-container",
+ "hidden",
+ "true"
+ );
+
+ this._cancelUpdate();
+ }
+}
+exports.ViewportSizeHighlighter = ViewportSizeHighlighter;
diff --git a/devtools/server/actors/inspector/constants.js b/devtools/server/actors/inspector/constants.js
new file mode 100644
index 0000000000..c253c67b02
--- /dev/null
+++ b/devtools/server/actors/inspector/constants.js
@@ -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/. */
+
+"use strict";
+
+/**
+ * Any event listener flagged with this symbol will not be considered when
+ * the EventCollector class enumerates listeners for nodes. For example:
+ *
+ * const someListener = () => {};
+ * someListener[EXCLUDED_LISTENER] = true;
+ * eventListenerService.addSystemEventListener(node, "event", someListener);
+ */
+const EXCLUDED_LISTENER = Symbol("event-collector-excluded-listener");
+
+exports.EXCLUDED_LISTENER = EXCLUDED_LISTENER;
diff --git a/devtools/server/actors/inspector/css-logic.js b/devtools/server/actors/inspector/css-logic.js
new file mode 100644
index 0000000000..8ef0978915
--- /dev/null
+++ b/devtools/server/actors/inspector/css-logic.js
@@ -0,0 +1,1604 @@
+/* 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/. */
+
+/*
+ * About the objects defined in this file:
+ * - CssLogic contains style information about a view context. It provides
+ * access to 2 sets of objects: Css[Sheet|Rule|Selector] provide access to
+ * information that does not change when the selected element changes while
+ * Css[Property|Selector]Info provide information that is dependent on the
+ * selected element.
+ * Its key methods are highlight(), getPropertyInfo() and forEachSheet(), etc
+ *
+ * - CssSheet provides a more useful API to a DOM CSSSheet for our purposes,
+ * including shortSource and href.
+ * - CssRule a more useful API to a DOM CSSRule including access to the group
+ * of CssSelectors that the rule provides properties for
+ * - CssSelector A single selector - i.e. not a selector group. In other words
+ * a CssSelector does not contain ','. This terminology is different from the
+ * standard DOM API, but more inline with the definition in the spec.
+ *
+ * - CssPropertyInfo contains style information for a single property for the
+ * highlighted element.
+ * - CssSelectorInfo is a wrapper around CssSelector, which adds sorting with
+ * reference to the selected element.
+ */
+
+"use strict";
+
+const nodeConstants = require("resource://devtools/shared/dom-node-constants.js");
+const {
+ getBindingElementAndPseudo,
+ getCSSStyleRules,
+ hasVisitedState,
+ isAgentStylesheet,
+ isAuthorStylesheet,
+ isUserStylesheet,
+ shortSource,
+ FILTER,
+ STATUS,
+} = require("resource://devtools/shared/inspector/css-logic.js");
+
+const COMPAREMODE = {
+ BOOLEAN: "bool",
+ INTEGER: "int",
+};
+
+class CssLogic {
+ constructor() {
+ this._propertyInfos = {};
+ }
+
+ // Both setup by highlight().
+ viewedElement = null;
+ viewedDocument = null;
+
+ // The cache of the known sheets.
+ _sheets = null;
+
+ // Have the sheets been cached?
+ _sheetsCached = false;
+
+ // The total number of rules, in all stylesheets, after filtering.
+ _ruleCount = 0;
+
+ // The computed styles for the viewedElement.
+ _computedStyle = null;
+
+ // Source filter. Only display properties coming from the given source
+ _sourceFilter = FILTER.USER;
+
+ // Used for tracking unique CssSheet/CssRule/CssSelector objects, in a run of
+ // processMatchedSelectors().
+ _passId = 0;
+
+ // Used for tracking matched CssSelector objects.
+ _matchId = 0;
+
+ _matchedRules = null;
+ _matchedSelectors = null;
+
+ // Cached keyframes rules in all stylesheets
+ _keyframesRules = null;
+
+ /**
+ * Reset various properties
+ */
+ reset() {
+ this._propertyInfos = {};
+ this._ruleCount = 0;
+ this._sheetIndex = 0;
+ this._sheets = {};
+ this._sheetsCached = false;
+ this._matchedRules = null;
+ this._matchedSelectors = null;
+ this._keyframesRules = [];
+ }
+
+ /**
+ * Focus on a new element - remove the style caches.
+ *
+ * @param {Element} aViewedElement the element the user has highlighted
+ * in the Inspector.
+ */
+ highlight(viewedElement) {
+ if (!viewedElement) {
+ this.viewedElement = null;
+ this.viewedDocument = null;
+ this._computedStyle = null;
+ this.reset();
+ return;
+ }
+
+ if (viewedElement === this.viewedElement) {
+ return;
+ }
+
+ this.viewedElement = viewedElement;
+
+ const doc = this.viewedElement.ownerDocument;
+ if (doc != this.viewedDocument) {
+ // New document: clear/rebuild the cache.
+ this.viewedDocument = doc;
+
+ // Hunt down top level stylesheets, and cache them.
+ this._cacheSheets();
+ } else {
+ // Clear cached data in the CssPropertyInfo objects.
+ this._propertyInfos = {};
+ }
+
+ this._matchedRules = null;
+ this._matchedSelectors = null;
+ this._computedStyle = CssLogic.getComputedStyle(this.viewedElement);
+ }
+
+ /**
+ * Get the values of all the computed CSS properties for the highlighted
+ * element.
+ * @returns {object} The computed CSS properties for a selected element
+ */
+ get computedStyle() {
+ return this._computedStyle;
+ }
+
+ /**
+ * Get the source filter.
+ * @returns {string} The source filter being used.
+ */
+ get sourceFilter() {
+ return this._sourceFilter;
+ }
+
+ /**
+ * Source filter. Only display properties coming from the given source (web
+ * address). Note that in order to avoid information overload we DO NOT show
+ * unmatched system rules.
+ * @see FILTER.*
+ */
+ set sourceFilter(value) {
+ const oldValue = this._sourceFilter;
+ this._sourceFilter = value;
+
+ let ruleCount = 0;
+
+ // Update the CssSheet objects.
+ this.forEachSheet(function (sheet) {
+ if (sheet.authorSheet && sheet.sheetAllowed) {
+ ruleCount += sheet.ruleCount;
+ }
+ }, this);
+
+ this._ruleCount = ruleCount;
+
+ // Full update is needed because the this.processMatchedSelectors() method
+ // skips UA stylesheets if the filter does not allow such sheets.
+ const needFullUpdate = oldValue == FILTER.UA || value == FILTER.UA;
+
+ if (needFullUpdate) {
+ this._matchedRules = null;
+ this._matchedSelectors = null;
+ this._propertyInfos = {};
+ } else {
+ // Update the CssPropertyInfo objects.
+ for (const property in this._propertyInfos) {
+ this._propertyInfos[property].needRefilter = true;
+ }
+ }
+ }
+
+ /**
+ * Return a CssPropertyInfo data structure for the currently viewed element
+ * and the specified CSS property. If there is no currently viewed element we
+ * return an empty object.
+ *
+ * @param {string} property The CSS property to look for.
+ * @return {CssPropertyInfo} a CssPropertyInfo structure for the given
+ * property.
+ */
+ getPropertyInfo(property) {
+ if (!this.viewedElement) {
+ return {};
+ }
+
+ let info = this._propertyInfos[property];
+ if (!info) {
+ info = new CssPropertyInfo(this, property);
+ this._propertyInfos[property] = info;
+ }
+
+ return info;
+ }
+
+ /**
+ * Cache all the stylesheets in the inspected document
+ * @private
+ */
+ _cacheSheets() {
+ this._passId++;
+ this.reset();
+
+ // styleSheets isn't an array, but forEach can work on it anyway
+ const styleSheets = InspectorUtils.getAllStyleSheets(
+ this.viewedDocument,
+ true
+ );
+ Array.prototype.forEach.call(styleSheets, this._cacheSheet, this);
+
+ this._sheetsCached = true;
+ }
+
+ /**
+ * Cache a stylesheet if it falls within the requirements: if it's enabled,
+ * and if the @media is allowed. This method also walks through the stylesheet
+ * cssRules to find @imported rules, to cache the stylesheets of those rules
+ * as well. In addition, the @keyframes rules in the stylesheet are cached.
+ *
+ * @private
+ * @param {CSSStyleSheet} domSheet the CSSStyleSheet object to cache.
+ */
+ _cacheSheet(domSheet) {
+ if (domSheet.disabled) {
+ return;
+ }
+
+ // Only work with stylesheets that have their media allowed.
+ if (!this.mediaMatches(domSheet)) {
+ return;
+ }
+
+ // Cache the sheet.
+ const cssSheet = this.getSheet(domSheet, this._sheetIndex++);
+ if (cssSheet._passId != this._passId) {
+ cssSheet._passId = this._passId;
+
+ // Find import and keyframes rules.
+ for (const aDomRule of cssSheet.getCssRules()) {
+ const ruleClassName = ChromeUtils.getClassName(aDomRule);
+ if (
+ ruleClassName === "CSSImportRule" &&
+ aDomRule.styleSheet &&
+ this.mediaMatches(aDomRule)
+ ) {
+ this._cacheSheet(aDomRule.styleSheet);
+ } else if (ruleClassName === "CSSKeyframesRule") {
+ this._keyframesRules.push(aDomRule);
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieve the list of stylesheets in the document.
+ *
+ * @return {array} the list of stylesheets in the document.
+ */
+ get sheets() {
+ if (!this._sheetsCached) {
+ this._cacheSheets();
+ }
+
+ const sheets = [];
+ this.forEachSheet(function (sheet) {
+ if (sheet.authorSheet) {
+ sheets.push(sheet);
+ }
+ }, this);
+
+ return sheets;
+ }
+
+ /**
+ * Retrieve the list of keyframes rules in the document.
+ *
+ * @ return {array} the list of keyframes rules in the document.
+ */
+ get keyframesRules() {
+ if (!this._sheetsCached) {
+ this._cacheSheets();
+ }
+ return this._keyframesRules;
+ }
+
+ /**
+ * Retrieve a CssSheet object for a given a CSSStyleSheet object. If the
+ * stylesheet is already cached, you get the existing CssSheet object,
+ * otherwise the new CSSStyleSheet object is cached.
+ *
+ * @param {CSSStyleSheet} domSheet the CSSStyleSheet object you want.
+ * @param {number} index the index, within the document, of the stylesheet.
+ *
+ * @return {CssSheet} the CssSheet object for the given CSSStyleSheet object.
+ */
+ getSheet(domSheet, index) {
+ let cacheId = "";
+
+ if (domSheet.href) {
+ cacheId = domSheet.href;
+ } else if (domSheet.associatedDocument) {
+ cacheId = domSheet.associatedDocument.location;
+ }
+
+ let sheet = null;
+ let sheetFound = false;
+
+ if (cacheId in this._sheets) {
+ for (sheet of this._sheets[cacheId]) {
+ if (sheet.domSheet === domSheet) {
+ if (index != -1) {
+ sheet.index = index;
+ }
+ sheetFound = true;
+ break;
+ }
+ }
+ }
+
+ if (!sheetFound) {
+ if (!(cacheId in this._sheets)) {
+ this._sheets[cacheId] = [];
+ }
+
+ sheet = new CssSheet(this, domSheet, index);
+ if (sheet.sheetAllowed && sheet.authorSheet) {
+ this._ruleCount += sheet.ruleCount;
+ }
+
+ this._sheets[cacheId].push(sheet);
+ }
+
+ return sheet;
+ }
+
+ /**
+ * Process each cached stylesheet in the document using your callback.
+ *
+ * @param {function} callback the function you want executed for each of the
+ * CssSheet objects cached.
+ * @param {object} scope the scope you want for the callback function. scope
+ * will be the this object when callback executes.
+ */
+ forEachSheet(callback, scope) {
+ for (const cacheId in this._sheets) {
+ const sheets = this._sheets[cacheId];
+ for (let i = 0; i < sheets.length; i++) {
+ // We take this as an opportunity to clean dead sheets
+ try {
+ const sheet = sheets[i];
+ // If accessing domSheet raises an exception, then the style
+ // sheet is a dead object.
+ sheet.domSheet;
+ callback.call(scope, sheet, i, sheets);
+ } catch (e) {
+ sheets.splice(i, 1);
+ i--;
+ }
+ }
+ }
+ }
+
+ /**
+
+ /**
+ * Get the number CSSRule objects in the document, counted from all of
+ * the stylesheets. System sheets are excluded. If a filter is active, this
+ * tells only the number of CSSRule objects inside the selected
+ * CSSStyleSheet.
+ *
+ * WARNING: This only provides an estimate of the rule count, and the results
+ * could change at a later date. Todo remove this
+ *
+ * @return {number} the number of CSSRule (all rules).
+ */
+ get ruleCount() {
+ if (!this._sheetsCached) {
+ this._cacheSheets();
+ }
+
+ return this._ruleCount;
+ }
+
+ /**
+ * Process the CssSelector objects that match the highlighted element and its
+ * parent elements. scope.callback() is executed for each CssSelector
+ * object, being passed the CssSelector object and the match status.
+ *
+ * This method also includes all of the element.style properties, for each
+ * highlighted element parent and for the highlighted element itself.
+ *
+ * Note that the matched selectors are cached, such that next time your
+ * callback is invoked for the cached list of CssSelector objects.
+ *
+ * @param {function} callback the function you want to execute for each of
+ * the matched selectors.
+ * @param {object} scope the scope you want for the callback function. scope
+ * will be the this object when callback executes.
+ */
+ processMatchedSelectors(callback, scope) {
+ if (this._matchedSelectors) {
+ if (callback) {
+ this._passId++;
+ this._matchedSelectors.forEach(function (value) {
+ callback.call(scope, value[0], value[1]);
+ value[0].cssRule._passId = this._passId;
+ }, this);
+ }
+ return;
+ }
+
+ if (!this._matchedRules) {
+ this._buildMatchedRules();
+ }
+
+ this._matchedSelectors = [];
+ this._passId++;
+
+ for (const matchedRule of this._matchedRules) {
+ const [rule, status, distance] = matchedRule;
+
+ rule.selectors.forEach(function (selector) {
+ if (
+ selector._matchId !== this._matchId &&
+ (selector.inlineStyle ||
+ this.selectorMatchesElement(rule.domRule, selector.selectorIndex))
+ ) {
+ selector._matchId = this._matchId;
+ this._matchedSelectors.push([selector, status, distance]);
+ if (callback) {
+ callback.call(scope, selector, status, distance);
+ }
+ }
+ }, this);
+
+ rule._passId = this._passId;
+ }
+ }
+
+ /**
+ * Check if the given selector matches the highlighted element or any of its
+ * parents.
+ *
+ * @private
+ * @param {DOMRule} domRule
+ * The DOM Rule containing the selector.
+ * @param {Number} idx
+ * The index of the selector within the DOMRule.
+ * @return {boolean}
+ * true if the given selector matches the highlighted element or any
+ * of its parents, otherwise false is returned.
+ */
+ selectorMatchesElement(domRule, idx) {
+ let element = this.viewedElement;
+ do {
+ if (domRule.selectorMatchesElement(idx, element)) {
+ return true;
+ }
+ } while (
+ // Loop on flattenedTreeParentNode instead of parentNode to reach the
+ // shadow host from the shadow dom.
+ (element = element.flattenedTreeParentNode) &&
+ element.nodeType === nodeConstants.ELEMENT_NODE
+ );
+
+ return false;
+ }
+
+ /**
+ * Check if the highlighted element or it's parents have matched selectors.
+ *
+ * @param {Array} properties: The list of properties you want to check if they
+ * have matched selectors or not.
+ * @return {object} An object that tells for each property if it has matched
+ * selectors or not. Object keys are property names and values are booleans.
+ */
+ hasMatchedSelectors(properties) {
+ if (!this._matchedRules) {
+ this._buildMatchedRules();
+ }
+
+ const result = {};
+
+ this._matchedRules.some(function (value) {
+ const rule = value[0];
+ const status = value[1];
+ properties = properties.filter(property => {
+ // We just need to find if a rule has this property while it matches
+ // the viewedElement (or its parents).
+ if (
+ rule.getPropertyValue(property) &&
+ (status == STATUS.MATCHED ||
+ (status == STATUS.PARENT_MATCH &&
+ InspectorUtils.isInheritedProperty(
+ this.viewedDocument,
+ property
+ )))
+ ) {
+ result[property] = true;
+ return false;
+ }
+ // Keep the property for the next rule.
+ return true;
+ });
+ return !properties.length;
+ }, this);
+
+ return result;
+ }
+
+ /**
+ * Build the array of matched rules for the currently highlighted element.
+ * The array will hold rules that match the viewedElement and its parents.
+ *
+ * @private
+ */
+ _buildMatchedRules() {
+ let domRules;
+ let element = this.viewedElement;
+ const filter = this.sourceFilter;
+ let sheetIndex = 0;
+
+ // distance is used to tell us how close an ancestor is to an element e.g.
+ // 0: The rule is directly applied to the current element.
+ // -1: The rule is inherited from the current element's first parent.
+ // -2: The rule is inherited from the current element's second parent.
+ // etc.
+ let distance = 0;
+
+ this._matchId++;
+ this._passId++;
+ this._matchedRules = [];
+
+ if (!element) {
+ return;
+ }
+
+ do {
+ const status =
+ this.viewedElement === element ? STATUS.MATCHED : STATUS.PARENT_MATCH;
+
+ try {
+ domRules = getCSSStyleRules(element);
+ } catch (ex) {
+ console.log("CL__buildMatchedRules error: " + ex);
+ continue;
+ }
+
+ // Add element.style information. Order matters here, and style attribute wins over
+ // other rules, so we need to add it in `this._matchesRules` before the regular rules.
+ if (element.style && element.style.length) {
+ const rule = new CssRule(null, { style: element.style }, element);
+ rule._matchId = this._matchId;
+ rule._passId = this._passId;
+ this._matchedRules.push([rule, status, distance]);
+ }
+
+ // getCSSStyleRules can return null with a shadow DOM element.
+ if (domRules !== null) {
+ // getCSSStyleRules returns ordered from least-specific to most-specific,
+ // but we do want them from most-specific to least specific, so we need to loop
+ // through the rules backward.
+ for (let i = domRules.length - 1; i >= 0; i--) {
+ const domRule = domRules[i];
+ if (!CSSStyleRule.isInstance(domRule)) {
+ continue;
+ }
+
+ const sheet = this.getSheet(domRule.parentStyleSheet, -1);
+ if (sheet._passId !== this._passId) {
+ sheet.index = sheetIndex++;
+ sheet._passId = this._passId;
+ }
+
+ if (filter === FILTER.USER && !sheet.authorSheet) {
+ continue;
+ }
+
+ const rule = sheet.getRule(domRule);
+ if (rule._passId === this._passId) {
+ continue;
+ }
+
+ rule._matchId = this._matchId;
+ rule._passId = this._passId;
+ this._matchedRules.push([rule, status, distance]);
+ }
+ }
+
+ distance--;
+ } while (
+ // Loop on flattenedTreeParentNode instead of parentNode to reach the
+ // shadow host from the shadow dom.
+ (element = element.flattenedTreeParentNode) &&
+ element.nodeType === nodeConstants.ELEMENT_NODE
+ );
+ }
+
+ /**
+ * Tells if the given DOM CSS object matches the current view media.
+ *
+ * @param {object} domObject The DOM CSS object to check.
+ * @return {boolean} True if the DOM CSS object matches the current view
+ * media, or false otherwise.
+ */
+ mediaMatches(domObject) {
+ const mediaText = domObject.media.mediaText;
+ return (
+ !mediaText ||
+ this.viewedDocument.defaultView.matchMedia(mediaText).matches
+ );
+ }
+}
+
+/**
+ * If the element has an id, return '#id'. Otherwise return 'tagname[n]' where
+ * n is the index of this element in its siblings.
+ * <p>A technically more 'correct' output from the no-id case might be:
+ * 'tagname:nth-of-type(n)' however this is unlikely to be more understood
+ * and it is longer.
+ *
+ * @param {Element} element the element for which you want the short name.
+ * @return {string} the string to be displayed for element.
+ */
+CssLogic.getShortName = function (element) {
+ if (!element) {
+ return "null";
+ }
+ if (element.id) {
+ return "#" + element.id;
+ }
+ let priorSiblings = 0;
+ let temp = element;
+ while ((temp = temp.previousElementSibling)) {
+ priorSiblings++;
+ }
+ return element.tagName + "[" + priorSiblings + "]";
+};
+
+/**
+ * Get a string list of selectors for a given DOMRule.
+ *
+ * @param {DOMRule} domRule
+ * The DOMRule to parse.
+ * @param {Boolean} desugared
+ * Set to true to get the desugared selector (see https://drafts.csswg.org/css-nesting-1/#nest-selector)
+ * @return {Array}
+ * An array of string selectors.
+ */
+CssLogic.getSelectors = function (domRule, desugared = false) {
+ if (ChromeUtils.getClassName(domRule) !== "CSSStyleRule") {
+ // Return empty array since CSSRule#selectorCount assumes only STYLE_RULE type.
+ return [];
+ }
+
+ const selectors = [];
+
+ const len = domRule.selectorCount;
+ for (let i = 0; i < len; i++) {
+ selectors.push(domRule.selectorTextAt(i, desugared));
+ }
+ return selectors;
+};
+
+/**
+ * Given a node, check to see if it is a ::before or ::after element.
+ * If so, return the node that is accessible from within the document
+ * (the parent of the anonymous node), along with which pseudo element
+ * it was. Otherwise, return the node itself.
+ *
+ * @returns {Object}
+ * - {DOMNode} node The non-anonymous node
+ * - {string} pseudo One of ':marker', ':before', ':after', or null.
+ */
+CssLogic.getBindingElementAndPseudo = getBindingElementAndPseudo;
+
+/**
+ * Get the computed style on a node. Automatically handles reading
+ * computed styles on a ::before/::after element by reading on the
+ * parent node with the proper pseudo argument.
+ *
+ * @param {Node}
+ * @returns {CSSStyleDeclaration}
+ */
+CssLogic.getComputedStyle = function (node) {
+ if (
+ !node ||
+ Cu.isDeadWrapper(node) ||
+ node.nodeType !== nodeConstants.ELEMENT_NODE ||
+ !node.ownerGlobal
+ ) {
+ return null;
+ }
+
+ const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(node);
+
+ // For reasons that still escape us, pseudo-elements can sometimes be "unattached" (i.e.
+ // not have a parentNode defined). This seems to happen when a page is reloaded while
+ // the inspector is open. Bailing out here ensures that the inspector does not fail at
+ // presenting DOM nodes and CSS styles when this happens. This is a temporary measure.
+ // See bug 1506792.
+ if (!bindingElement) {
+ return null;
+ }
+
+ return node.ownerGlobal.getComputedStyle(bindingElement, pseudo);
+};
+
+/**
+ * Get a source for a stylesheet, taking into account embedded stylesheets
+ * for which we need to use document.defaultView.location.href rather than
+ * sheet.href
+ *
+ * @param {CSSStyleSheet} sheet the DOM object for the style sheet.
+ * @return {string} the address of the stylesheet.
+ */
+CssLogic.href = function (sheet) {
+ return sheet.href || sheet.associatedDocument.location;
+};
+
+/**
+ * Returns true if the given node has visited state.
+ */
+CssLogic.hasVisitedState = hasVisitedState;
+
+class CssSheet {
+ /**
+ * A safe way to access cached bits of information about a stylesheet.
+ *
+ * @constructor
+ * @param {CssLogic} cssLogic pointer to the CssLogic instance working with
+ * this CssSheet object.
+ * @param {CSSStyleSheet} domSheet reference to a DOM CSSStyleSheet object.
+ * @param {number} index tells the index/position of the stylesheet within the
+ * main document.
+ */
+ constructor(cssLogic, domSheet, index) {
+ this._cssLogic = cssLogic;
+ this.domSheet = domSheet;
+ this.index = this.authorSheet ? index : -100 * index;
+
+ // Cache of the sheets href. Cached by the getter.
+ this._href = null;
+ // Short version of href for use in select boxes etc. Cached by getter.
+ this._shortSource = null;
+
+ // null for uncached.
+ this._sheetAllowed = null;
+
+ // Cached CssRules from the given stylesheet.
+ this._rules = {};
+
+ this._ruleCount = -1;
+ }
+
+ _passId = null;
+ _agentSheet = null;
+ _authorSheet = null;
+ _userSheet = null;
+
+ /**
+ * Check if the stylesheet is an agent stylesheet (provided by the browser).
+ *
+ * @return {boolean} true if this is an agent stylesheet, false otherwise.
+ */
+ get agentSheet() {
+ if (this._agentSheet === null) {
+ this._agentSheet = isAgentStylesheet(this.domSheet);
+ }
+ return this._agentSheet;
+ }
+
+ /**
+ * Check if the stylesheet is an author stylesheet (provided by the content page).
+ *
+ * @return {boolean} true if this is an author stylesheet, false otherwise.
+ */
+ get authorSheet() {
+ if (this._authorSheet === null) {
+ this._authorSheet = isAuthorStylesheet(this.domSheet);
+ }
+ return this._authorSheet;
+ }
+
+ /**
+ * Check if the stylesheet is a user stylesheet (provided by userChrome.css or
+ * userContent.css).
+ *
+ * @return {boolean} true if this is a user stylesheet, false otherwise.
+ */
+ get userSheet() {
+ if (this._userSheet === null) {
+ this._userSheet = isUserStylesheet(this.domSheet);
+ }
+ return this._userSheet;
+ }
+
+ /**
+ * Check if the stylesheet is disabled or not.
+ * @return {boolean} true if this stylesheet is disabled, or false otherwise.
+ */
+ get disabled() {
+ return this.domSheet.disabled;
+ }
+
+ /**
+ * Get a source for a stylesheet, using CssLogic.href
+ *
+ * @return {string} the address of the stylesheet.
+ */
+ get href() {
+ if (this._href) {
+ return this._href;
+ }
+
+ this._href = CssLogic.href(this.domSheet);
+ return this._href;
+ }
+
+ /**
+ * Create a shorthand version of the href of a stylesheet.
+ *
+ * @return {string} the shorthand source of the stylesheet.
+ */
+ get shortSource() {
+ if (this._shortSource) {
+ return this._shortSource;
+ }
+
+ this._shortSource = shortSource(this.domSheet);
+ return this._shortSource;
+ }
+
+ /**
+ * Tells if the sheet is allowed or not by the current CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the stylesheet is allowed by the sourceFilter, or
+ * false otherwise.
+ */
+ get sheetAllowed() {
+ if (this._sheetAllowed !== null) {
+ return this._sheetAllowed;
+ }
+
+ this._sheetAllowed = true;
+
+ const filter = this._cssLogic.sourceFilter;
+ if (filter === FILTER.USER && !this.authorSheet) {
+ this._sheetAllowed = false;
+ }
+ if (filter !== FILTER.USER && filter !== FILTER.UA) {
+ this._sheetAllowed = filter === this.href;
+ }
+
+ return this._sheetAllowed;
+ }
+
+ /**
+ * Retrieve the number of rules in this stylesheet.
+ *
+ * @return {number} the number of CSSRule objects in this stylesheet.
+ */
+ get ruleCount() {
+ try {
+ return this._ruleCount > -1 ? this._ruleCount : this.getCssRules().length;
+ } catch (e) {
+ return 0;
+ }
+ }
+
+ /**
+ * Retrieve the array of css rules for this stylesheet.
+ *
+ * Accessing cssRules on a stylesheet that is not completely loaded can throw a
+ * DOMException (Bug 625013). This wrapper will return an empty array instead.
+ *
+ * @return {Array} array of css rules.
+ **/
+ getCssRules() {
+ try {
+ return this.domSheet.cssRules;
+ } catch (e) {
+ return [];
+ }
+ }
+
+ /**
+ * Retrieve a CssRule object for the given CSSStyleRule. The CssRule object is
+ * cached, such that subsequent retrievals return the same CssRule object for
+ * the same CSSStyleRule object.
+ *
+ * @param {CSSStyleRule} aDomRule the CSSStyleRule object for which you want a
+ * CssRule object.
+ * @return {CssRule} the cached CssRule object for the given CSSStyleRule
+ * object.
+ */
+ getRule(domRule) {
+ const cacheId = domRule.type + domRule.selectorText;
+
+ let rule = null;
+ let ruleFound = false;
+
+ if (cacheId in this._rules) {
+ for (rule of this._rules[cacheId]) {
+ if (rule.domRule === domRule) {
+ ruleFound = true;
+ break;
+ }
+ }
+ }
+
+ if (!ruleFound) {
+ if (!(cacheId in this._rules)) {
+ this._rules[cacheId] = [];
+ }
+
+ rule = new CssRule(this, domRule);
+ this._rules[cacheId].push(rule);
+ }
+
+ return rule;
+ }
+
+ toString() {
+ return "CssSheet[" + this.shortSource + "]";
+ }
+}
+
+class CssRule {
+ /**
+ * Information about a single CSSStyleRule.
+ *
+ * @param {CSSStyleSheet|null} cssSheet the CssSheet object of the stylesheet that
+ * holds the CSSStyleRule. If the rule comes from element.style, set this
+ * argument to null.
+ * @param {CSSStyleRule|object} domRule the DOM CSSStyleRule for which you want
+ * to cache data. If the rule comes from element.style, then provide
+ * an object of the form: {style: element.style}.
+ * @param {Element} [element] If the rule comes from element.style, then this
+ * argument must point to the element.
+ * @constructor
+ */
+ constructor(cssSheet, domRule, element) {
+ this._cssSheet = cssSheet;
+ this.domRule = domRule;
+
+ if (this._cssSheet) {
+ // parse domRule.selectorText on call to this.selectors
+ this._selectors = null;
+ this.line = InspectorUtils.getRelativeRuleLine(this.domRule);
+ this.column = InspectorUtils.getRuleColumn(this.domRule);
+ this.href = this._cssSheet.href;
+ this.authorRule = this._cssSheet.authorSheet;
+ this.userRule = this._cssSheet.userSheet;
+ this.agentRule = this._cssSheet.agentSheet;
+ } else if (element) {
+ this._selectors = [new CssSelector(this, "@element.style", 0)];
+ this.line = -1;
+ this.href = "#";
+ this.authorRule = true;
+ this.userRule = false;
+ this.agentRule = false;
+ this.sourceElement = element;
+ }
+ }
+
+ _passId = null;
+
+ /**
+ * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the parent stylesheet is allowed by the current
+ * sourceFilter, or false otherwise.
+ */
+ get sheetAllowed() {
+ return this._cssSheet ? this._cssSheet.sheetAllowed : true;
+ }
+
+ /**
+ * Retrieve the parent stylesheet index/position in the viewed document.
+ *
+ * @return {number} the parent stylesheet index/position in the viewed
+ * document.
+ */
+ get sheetIndex() {
+ return this._cssSheet ? this._cssSheet.index : 0;
+ }
+
+ /**
+ * Retrieve the style property value from the current CSSStyleRule.
+ *
+ * @param {string} property the CSS property name for which you want the
+ * value.
+ * @return {string} the property value.
+ */
+ getPropertyValue(property) {
+ return this.domRule.style.getPropertyValue(property);
+ }
+
+ /**
+ * Retrieve the style property priority from the current CSSStyleRule.
+ *
+ * @param {string} property the CSS property name for which you want the
+ * priority.
+ * @return {string} the property priority.
+ */
+ getPropertyPriority(property) {
+ return this.domRule.style.getPropertyPriority(property);
+ }
+
+ /**
+ * Retrieve the list of CssSelector objects for each of the parsed selectors
+ * of the current CSSStyleRule.
+ *
+ * @return {array} the array hold the CssSelector objects.
+ */
+ get selectors() {
+ if (this._selectors) {
+ return this._selectors;
+ }
+
+ // Parse the CSSStyleRule.selectorText string.
+ this._selectors = [];
+
+ if (!this.domRule.selectorText) {
+ return this._selectors;
+ }
+
+ const selectors = CssLogic.getSelectors(this.domRule);
+
+ for (let i = 0, len = selectors.length; i < len; i++) {
+ this._selectors.push(new CssSelector(this, selectors[i], i));
+ }
+
+ return this._selectors;
+ }
+
+ toString() {
+ return "[CssRule " + this.domRule.selectorText + "]";
+ }
+}
+
+class CssSelector {
+ /**
+ * The CSS selector class allows us to document the ranking of various CSS
+ * selectors.
+ *
+ * @constructor
+ * @param {CssRule} cssRule the CssRule instance from where the selector comes.
+ * @param {string} selector The selector that we wish to investigate.
+ * @param {Number} index The index of the selector within it's rule.
+ */
+ constructor(cssRule, selector, index) {
+ this.cssRule = cssRule;
+ this.text = selector;
+ this.inlineStyle = this.text == "@element.style";
+ this._specificity = null;
+ this.selectorIndex = index;
+ }
+
+ _matchId = null;
+
+ /**
+ * Retrieve the CssSelector source element, which is the source of the CssRule
+ * owning the selector. This is only available when the CssSelector comes from
+ * an element.style.
+ *
+ * @return {string} the source element selector.
+ */
+ get sourceElement() {
+ return this.cssRule.sourceElement;
+ }
+
+ /**
+ * Retrieve the address of the CssSelector. This points to the address of the
+ * CssSheet owning this selector.
+ *
+ * @return {string} the address of the CssSelector.
+ */
+ get href() {
+ return this.cssRule.href;
+ }
+
+ /**
+ * Check if the selector comes from an agent stylesheet (provided by the browser).
+ *
+ * @return {boolean} true if this is an agent stylesheet, false otherwise.
+ */
+ get agentRule() {
+ return this.cssRule.agentRule;
+ }
+
+ /**
+ * Check if the selector comes from an author stylesheet (provided by the content page).
+ *
+ * @return {boolean} true if this is an author stylesheet, false otherwise.
+ */
+ get authorRule() {
+ return this.cssRule.authorRule;
+ }
+
+ /**
+ * Check if the selector comes from a user stylesheet (provided by userChrome.css or
+ * userContent.css).
+ *
+ * @return {boolean} true if this is a user stylesheet, false otherwise.
+ */
+ get userRule() {
+ return this.cssRule.userRule;
+ }
+
+ /**
+ * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the parent stylesheet is allowed by the current
+ * sourceFilter, or false otherwise.
+ */
+ get sheetAllowed() {
+ return this.cssRule.sheetAllowed;
+ }
+
+ /**
+ * Retrieve the parent stylesheet index/position in the viewed document.
+ *
+ * @return {number} the parent stylesheet index/position in the viewed
+ * document.
+ */
+ get sheetIndex() {
+ return this.cssRule.sheetIndex;
+ }
+
+ /**
+ * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the line of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleLine() {
+ return this.cssRule.line;
+ }
+
+ /**
+ * Retrieve the column of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the column of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleColumn() {
+ return this.cssRule.column;
+ }
+
+ /**
+ * Retrieve specificity information for the current selector.
+ *
+ * @see http://www.w3.org/TR/css3-selectors/#specificity
+ * @see http://www.w3.org/TR/CSS2/selector.html
+ *
+ * @return {Number} The selector's specificity.
+ */
+ get specificity() {
+ if (this.inlineStyle) {
+ // We can't ask specificity from DOMUtils as element styles don't provide
+ // CSSStyleRule interface DOMUtils expect. However, specificity of element
+ // style is constant, 1,0,0,0 or 0x40000000, just return the constant
+ // directly. @see http://www.w3.org/TR/CSS2/cascade.html#specificity
+ return 0x40000000;
+ }
+
+ if (typeof this._specificity !== "number") {
+ this._specificity = this.cssRule.domRule.selectorSpecificityAt(
+ this.selectorIndex
+ );
+ }
+
+ return this._specificity;
+ }
+
+ toString() {
+ return this.text;
+ }
+}
+
+class CssPropertyInfo {
+ /**
+ * A cache of information about the matched rules, selectors and values attached
+ * to a CSS property, for the highlighted element.
+ *
+ * The heart of the CssPropertyInfo object is the _findMatchedSelectors()
+ * method. This are invoked when the PropertyView tries to access the
+ * .matchedSelectors array.
+ * Results are cached, for later reuse.
+ *
+ * @param {CssLogic} cssLogic Reference to the parent CssLogic instance
+ * @param {string} property The CSS property we are gathering information for
+ * @constructor
+ */
+ constructor(cssLogic, property) {
+ this._cssLogic = cssLogic;
+ this.property = property;
+ this._value = "";
+
+ // An array holding CssSelectorInfo objects for each of the matched selectors
+ // that are inside a CSS rule. Only rules that hold the this.property are
+ // counted. This includes rules that come from filtered stylesheets (those
+ // that have sheetAllowed = false).
+ this._matchedSelectors = null;
+ }
+
+ /**
+ * Retrieve the computed style value for the current property, for the
+ * highlighted element.
+ *
+ * @return {string} the computed style value for the current property, for the
+ * highlighted element.
+ */
+ get value() {
+ if (!this._value && this._cssLogic.computedStyle) {
+ try {
+ this._value = this._cssLogic.computedStyle.getPropertyValue(
+ this.property
+ );
+ } catch (ex) {
+ console.log("Error reading computed style for " + this.property);
+ console.log(ex);
+ }
+ }
+ return this._value;
+ }
+
+ /**
+ * Retrieve the array holding CssSelectorInfo objects for each of the matched
+ * selectors, from each of the matched rules. Only selectors coming from
+ * allowed stylesheets are included in the array.
+ *
+ * @return {array} the list of CssSelectorInfo objects of selectors that match
+ * the highlighted element and its parents.
+ */
+ get matchedSelectors() {
+ if (!this._matchedSelectors) {
+ this._findMatchedSelectors();
+ } else if (this.needRefilter) {
+ this._refilterSelectors();
+ }
+
+ return this._matchedSelectors;
+ }
+
+ /**
+ * Find the selectors that match the highlighted element and its parents.
+ * Uses CssLogic.processMatchedSelectors() to find the matched selectors,
+ * passing in a reference to CssPropertyInfo._processMatchedSelector() to
+ * create CssSelectorInfo objects, which we then sort
+ * @private
+ */
+ _findMatchedSelectors() {
+ this._matchedSelectors = [];
+ this.needRefilter = false;
+
+ this._cssLogic.processMatchedSelectors(this._processMatchedSelector, this);
+
+ // Sort the selectors by how well they match the given element.
+ this._matchedSelectors.sort((selectorInfo1, selectorInfo2) =>
+ selectorInfo1.compareTo(selectorInfo2, this._matchedSelectors)
+ );
+
+ // Now we know which of the matches is best, we can mark it BEST_MATCH.
+ if (
+ this._matchedSelectors.length &&
+ this._matchedSelectors[0].status > STATUS.UNMATCHED
+ ) {
+ this._matchedSelectors[0].status = STATUS.BEST;
+ }
+ }
+
+ /**
+ * Process a matched CssSelector object.
+ *
+ * @private
+ * @param {CssSelector} selector: the matched CssSelector object.
+ * @param {STATUS} status: the CssSelector match status.
+ * @param {Int} distance: See CssLogic._buildMatchedRules for definition.
+ */
+ _processMatchedSelector(selector, status, distance) {
+ const cssRule = selector.cssRule;
+ const value = cssRule.getPropertyValue(this.property);
+ if (
+ value &&
+ (status == STATUS.MATCHED ||
+ (status == STATUS.PARENT_MATCH &&
+ InspectorUtils.isInheritedProperty(
+ this._cssLogic.viewedDocument,
+ this.property
+ )))
+ ) {
+ const selectorInfo = new CssSelectorInfo(
+ selector,
+ this.property,
+ value,
+ status,
+ distance
+ );
+ this._matchedSelectors.push(selectorInfo);
+ }
+ }
+
+ /**
+ * Refilter the matched selectors array when the CssLogic.sourceFilter
+ * changes. This allows for quick filter changes.
+ * @private
+ */
+ _refilterSelectors() {
+ const passId = ++this._cssLogic._passId;
+
+ const iterator = function (selectorInfo) {
+ const cssRule = selectorInfo.selector.cssRule;
+ if (cssRule._passId != passId) {
+ cssRule._passId = passId;
+ }
+ };
+
+ if (this._matchedSelectors) {
+ this._matchedSelectors.forEach(iterator);
+ }
+
+ this.needRefilter = false;
+ }
+
+ toString() {
+ return "CssPropertyInfo[" + this.property + "]";
+ }
+}
+
+class CssSelectorInfo {
+ /**
+ * A class that holds information about a given CssSelector object.
+ *
+ * Instances of this class are given to CssHtmlTree in the array of matched
+ * selectors. Each such object represents a displayable row in the PropertyView
+ * objects. The information given by this object blends data coming from the
+ * CssSheet, CssRule and from the CssSelector that own this object.
+ *
+ * @param {CssSelector} selector The CssSelector object for which to
+ * present information.
+ * @param {string} property The property for which information should
+ * be retrieved.
+ * @param {string} value The property value from the CssRule that owns
+ * the selector.
+ * @param {STATUS} status The selector match status.
+ * @param {number} distance See CssLogic._buildMatchedRules for definition.
+ * @constructor
+ */
+ constructor(selector, property, value, status, distance) {
+ this.selector = selector;
+ this.property = property;
+ this.status = status;
+ this.distance = distance;
+ this.value = value;
+ const priority = this.selector.cssRule.getPropertyPriority(this.property);
+ this.important = priority === "important";
+
+ // Array<string|CSSLayerBlockRule>
+ this.parentLayers = [];
+
+ // Go through all parent rules to populate this.parentLayers
+ let rule = selector.cssRule.domRule;
+ while (rule) {
+ const className = ChromeUtils.getClassName(rule);
+ if (className == "CSSLayerBlockRule") {
+ // If the layer has a name, it's enough to uniquely identify it
+ // If the layer does not have a name. We put the actual rule here, so we'll
+ // be able to compare actual rule instances in `compareTo`
+ this.parentLayers.push(rule.name || rule);
+ } else if (className == "CSSImportRule" && rule.layerName !== null) {
+ // Same reasoning for @import rule + layer
+ this.parentLayers.push(rule.layerName || rule);
+ }
+
+ // Get the parent rule (could be the parent stylesheet owner rule
+ // for `@import url(path/to/file.css) layer`)
+ rule = rule.parentRule || rule.parentStyleSheet?.ownerRule;
+ }
+ }
+
+ /**
+ * Retrieve the CssSelector source element, which is the source of the CssRule
+ * owning the selector. This is only available when the CssSelector comes from
+ * an element.style.
+ *
+ * @return {string} the source element selector.
+ */
+ get sourceElement() {
+ return this.selector.sourceElement;
+ }
+
+ /**
+ * Retrieve the address of the CssSelector. This points to the address of the
+ * CssSheet owning this selector.
+ *
+ * @return {string} the address of the CssSelector.
+ */
+ get href() {
+ return this.selector.href;
+ }
+
+ /**
+ * Check if the CssSelector comes from element.style or not.
+ *
+ * @return {boolean} true if the CssSelector comes from element.style, or
+ * false otherwise.
+ */
+ get inlineStyle() {
+ return this.selector.inlineStyle;
+ }
+
+ /**
+ * Retrieve specificity information for the current selector.
+ *
+ * @return {object} an object holding specificity information for the current
+ * selector.
+ */
+ get specificity() {
+ return this.selector.specificity;
+ }
+
+ /**
+ * Retrieve the parent stylesheet index/position in the viewed document.
+ *
+ * @return {number} the parent stylesheet index/position in the viewed
+ * document.
+ */
+ get sheetIndex() {
+ return this.selector.sheetIndex;
+ }
+
+ /**
+ * Check if the parent stylesheet is allowed by the CssLogic.sourceFilter.
+ *
+ * @return {boolean} true if the parent stylesheet is allowed by the current
+ * sourceFilter, or false otherwise.
+ */
+ get sheetAllowed() {
+ return this.selector.sheetAllowed;
+ }
+
+ /**
+ * Retrieve the line of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the line of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleLine() {
+ return this.selector.ruleLine;
+ }
+
+ /**
+ * Retrieve the column of the parent CSSStyleRule in the parent CSSStyleSheet.
+ *
+ * @return {number} the column of the parent CSSStyleRule in the parent
+ * stylesheet.
+ */
+ get ruleColumn() {
+ return this.selector.ruleColumn;
+ }
+
+ /**
+ * Check if the selector comes from a browser-provided stylesheet.
+ *
+ * @return {boolean} true if the selector comes from a browser-provided
+ * stylesheet, or false otherwise.
+ */
+ get agentRule() {
+ return this.selector.agentRule;
+ }
+
+ /**
+ * Check if the selector comes from a webpage-provided stylesheet.
+ *
+ * @return {boolean} true if the selector comes from a webpage-provided
+ * stylesheet, or false otherwise.
+ */
+ get authorRule() {
+ return this.selector.authorRule;
+ }
+
+ /**
+ * Check if the selector comes from a user stylesheet (userChrome.css or
+ * userContent.css).
+ *
+ * @return {boolean} true if the selector comes from a webpage-provided
+ * stylesheet, or false otherwise.
+ */
+ get userRule() {
+ return this.selector.userRule;
+ }
+
+ /**
+ * Compare the current CssSelectorInfo instance to another instance.
+ * Since selectorInfos is computed from `InspectorUtils.getCSSStyleRules`,
+ * it's already sorted for regular cases. We only need to handle important values.
+ *
+ * @param {CssSelectorInfo} that
+ * The instance to compare ourselves against.
+ * @param {Array<CssSelectorInfo>} selectorInfos
+ * The list of CssSelectorInfo we are currently ordering
+ * @return {Number}
+ * -1, 0, 1 depending on how that compares with this.
+ */
+ compareTo(that, selectorInfos) {
+ const originalOrder =
+ selectorInfos.indexOf(this) < selectorInfos.indexOf(that) ? -1 : 1;
+
+ // If both properties are not important, we can keep the original order
+ if (!this.important && !that.important) {
+ return originalOrder;
+ }
+
+ // If one of the property is important and the other is not, the important one wins
+ if (this.important !== that.important) {
+ return this.important ? -1 : 1;
+ }
+
+ // At this point, this and that are both important
+
+ const thisIsInLayer = !!this.parentLayers.length;
+ const thatIsInLayer = !!that.parentLayers.length;
+
+ // If they're not in layers, we can keep the original rule order
+ if (!thisIsInLayer && !thatIsInLayer) {
+ return originalOrder;
+ }
+
+ // If one of the rule is the style attribute, it wins
+ if (this.selector.inlineStyle || that.selector.inlineStyle) {
+ return this.selector.inlineStyle ? -1 : 1;
+ }
+
+ // If one of the rule is not in a layer, then the rule in a layer wins.
+ if (!thisIsInLayer || !thatIsInLayer) {
+ return thisIsInLayer ? -1 : 1;
+ }
+
+ const inSameLayers =
+ this.parentLayers.length === that.parentLayers.length &&
+ this.parentLayers.every((layer, i) => layer === that.parentLayers[i]);
+ // If both rules are in the same layer, we keep the original order
+ if (inSameLayers) {
+ return originalOrder;
+ }
+
+ // When comparing declarations that belong to different layers, then for
+ // important rules the declaration whose cascade layer is first wins.
+ // We get the rules in the most-specific to least-specific order, meaning we'll have
+ // rules in layers in the reverse order of the order of declarations of layers.
+ // We can reverse that again to get the order of declarations of layers.
+ return originalOrder * -1;
+ }
+
+ compare(that, propertyName, type) {
+ switch (type) {
+ case COMPAREMODE.BOOLEAN:
+ if (this[propertyName] && !that[propertyName]) {
+ return -1;
+ }
+ if (!this[propertyName] && that[propertyName]) {
+ return 1;
+ }
+ break;
+ case COMPAREMODE.INTEGER:
+ if (this[propertyName] > that[propertyName]) {
+ return -1;
+ }
+ if (this[propertyName] < that[propertyName]) {
+ return 1;
+ }
+ break;
+ }
+ return 0;
+ }
+
+ toString() {
+ return this.selector + " -> " + this.value;
+ }
+}
+
+exports.CssLogic = CssLogic;
+exports.CssSelector = CssSelector;
diff --git a/devtools/server/actors/inspector/custom-element-watcher.js b/devtools/server/actors/inspector/custom-element-watcher.js
new file mode 100644
index 0000000000..8eb57fea40
--- /dev/null
+++ b/devtools/server/actors/inspector/custom-element-watcher.js
@@ -0,0 +1,144 @@
+/* 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");
+
+/**
+ * The CustomElementWatcher can be used to be notified if a custom element definition
+ * is created for a node.
+ *
+ * When a custom element is defined for a monitored name, an "element-defined" event is
+ * fired with the following Object argument:
+ * - {String} name: name of the custom element defined
+ * - {Set} Set of impacted node actors
+ */
+class CustomElementWatcher extends EventEmitter {
+ constructor(chromeEventHandler) {
+ super();
+
+ this.chromeEventHandler = chromeEventHandler;
+ this._onCustomElementDefined = this._onCustomElementDefined.bind(this);
+ this.chromeEventHandler.addEventListener(
+ "customelementdefined",
+ this._onCustomElementDefined
+ );
+
+ /**
+ * Each window keeps its own custom element registry, all of them are watched
+ * separately. The struture of the watchedRegistries is as follows
+ *
+ * WeakMap(
+ * registry -> Map (
+ * name -> Set(NodeActors)
+ * )
+ * )
+ */
+ this.watchedRegistries = new WeakMap();
+ }
+
+ destroy() {
+ this.watchedRegistries = null;
+ this.chromeEventHandler.removeEventListener(
+ "customelementdefined",
+ this._onCustomElementDefined
+ );
+ }
+
+ /**
+ * Watch for custom element definitions matching the name of the provided NodeActor.
+ */
+ manageNode(nodeActor) {
+ if (!this._isValidNode(nodeActor)) {
+ return;
+ }
+
+ if (!this._shouldWatchDefinition(nodeActor)) {
+ return;
+ }
+
+ const registry = nodeActor.rawNode.ownerGlobal.customElements;
+ const registryMap = this._getMapForRegistry(registry);
+
+ const name = nodeActor.rawNode.localName;
+ const actorsSet = this._getActorsForName(name, registryMap);
+ actorsSet.add(nodeActor);
+ }
+
+ /**
+ * Stop watching the provided NodeActor.
+ */
+ unmanageNode(nodeActor) {
+ if (!this._isValidNode(nodeActor)) {
+ return;
+ }
+
+ const win = nodeActor.rawNode.ownerGlobal;
+ const registry = win.customElements;
+ const registryMap = this._getMapForRegistry(registry);
+ const name = nodeActor.rawNode.localName;
+ if (registryMap.has(name)) {
+ registryMap.get(name).delete(nodeActor);
+ }
+ }
+
+ /**
+ * Retrieve the map of name->nodeActors for a given CustomElementsRegistry.
+ * Will create the map if not created yet.
+ */
+ _getMapForRegistry(registry) {
+ if (!this.watchedRegistries.has(registry)) {
+ this.watchedRegistries.set(registry, new Map());
+ }
+ return this.watchedRegistries.get(registry);
+ }
+
+ /**
+ * Retrieve the set of nodeActors for a given name and registry.
+ * Will create the set if not created yet.
+ */
+ _getActorsForName(name, registryMap) {
+ if (!registryMap.has(name)) {
+ registryMap.set(name, new Set());
+ }
+ return registryMap.get(name);
+ }
+
+ _shouldWatchDefinition(nodeActor) {
+ const doc = nodeActor.rawNode.ownerDocument;
+ const namespaceURI = doc.documentElement.namespaceURI;
+ const name = nodeActor.rawNode.localName;
+ const isValidName = InspectorUtils.isCustomElementName(name, namespaceURI);
+
+ const customElements = doc.defaultView.customElements;
+ return isValidName && !customElements.get(name);
+ }
+
+ _onCustomElementDefined(event) {
+ const doc = event.target;
+ const registry = doc.defaultView.customElements;
+ const registryMap = this._getMapForRegistry(registry);
+
+ const name = event.detail;
+ const actors = this._getActorsForName(name, registryMap);
+ this.emit("element-defined", { name, actors });
+ registryMap.delete(name);
+ }
+
+ /**
+ * Some nodes (e.g. inside of <template> tags) don't have a documentElement or an
+ * ownerGlobal and can't be watched by this helper.
+ */
+ _isValidNode(nodeActor) {
+ const node = nodeActor.rawNode;
+ return (
+ !Cu.isDeadWrapper(node) &&
+ node.ownerGlobal &&
+ node.ownerDocument?.documentElement
+ );
+ }
+}
+
+exports.CustomElementWatcher = CustomElementWatcher;
diff --git a/devtools/server/actors/inspector/document-walker.js b/devtools/server/actors/inspector/document-walker.js
new file mode 100644
index 0000000000..7ced18ecd8
--- /dev/null
+++ b/devtools/server/actors/inspector/document-walker.js
@@ -0,0 +1,196 @@
+/* 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,
+ "nodeFilterConstants",
+ "resource://devtools/shared/dom-node-filter-constants.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "standardTreeWalkerFilter",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+// SKIP_TO_* arguments are used with the DocumentWalker, driving the strategy to use if
+// the starting node is incompatible with the filter function of the walker.
+const SKIP_TO_PARENT = "SKIP_TO_PARENT";
+const SKIP_TO_SIBLING = "SKIP_TO_SIBLING";
+
+class DocumentWalker {
+ /**
+ * Wrapper for inDeepTreeWalker. Adds filtering to the traversal methods.
+ * See inDeepTreeWalker for more information about the methods.
+ *
+ * @param {DOMNode} node
+ * @param {Window} rootWin
+ * @param {Object}
+ * - {Function} filter
+ * A custom filter function Taking in a DOMNode and returning an Int. See
+ * WalkerActor.nodeFilter for an example.
+ * - {String} skipTo
+ * Either SKIP_TO_PARENT or SKIP_TO_SIBLING. If the provided node is not
+ * compatible with the filter function for this walker, try to find a compatible
+ * one either in the parents or in the siblings of the node.
+ * - {Boolean} showAnonymousContent
+ * Pass true to let the walker return and traverse anonymous content.
+ * When navigating host elements to which shadow DOM is attached, the light tree
+ * will be visible only to a walker with showAnonymousContent=false. The shadow
+ * tree will only be visible to a walker with showAnonymousContent=true.
+ */
+ constructor(
+ node,
+ rootWin,
+ {
+ filter = standardTreeWalkerFilter,
+ skipTo = SKIP_TO_PARENT,
+ showAnonymousContent = true,
+ } = {}
+ ) {
+ if (Cu.isDeadWrapper(rootWin) || !rootWin.location) {
+ throw new Error("Got an invalid root window in DocumentWalker");
+ }
+
+ this.walker = Cc[
+ "@mozilla.org/inspector/deep-tree-walker;1"
+ ].createInstance(Ci.inIDeepTreeWalker);
+ this.walker.showAnonymousContent = showAnonymousContent;
+ this.walker.showSubDocuments = true;
+ this.walker.showDocumentsAsNodes = true;
+ this.walker.init(rootWin.document);
+ this.filter = filter;
+
+ // Make sure that the walker knows about the initial node (which could
+ // be skipped due to a filter).
+ this.walker.currentNode = this.getStartingNode(node, skipTo);
+ }
+
+ get currentNode() {
+ return this.walker.currentNode;
+ }
+ set currentNode(val) {
+ this.walker.currentNode = val;
+ }
+
+ parentNode() {
+ return this.walker.parentNode();
+ }
+
+ nextNode() {
+ const node = this.walker.currentNode;
+ if (!node) {
+ return null;
+ }
+
+ let nextNode = this.walker.nextNode();
+ while (nextNode && this.isSkippedNode(nextNode)) {
+ nextNode = this.walker.nextNode();
+ }
+
+ return nextNode;
+ }
+
+ firstChild() {
+ if (!this.walker.currentNode) {
+ return null;
+ }
+
+ let firstChild = this.walker.firstChild();
+ while (firstChild && this.isSkippedNode(firstChild)) {
+ firstChild = this.walker.nextSibling();
+ }
+
+ return firstChild;
+ }
+
+ lastChild() {
+ if (!this.walker.currentNode) {
+ return null;
+ }
+
+ let lastChild = this.walker.lastChild();
+ while (lastChild && this.isSkippedNode(lastChild)) {
+ lastChild = this.walker.previousSibling();
+ }
+
+ return lastChild;
+ }
+
+ previousSibling() {
+ let node = this.walker.previousSibling();
+ while (node && this.isSkippedNode(node)) {
+ node = this.walker.previousSibling();
+ }
+ return node;
+ }
+
+ nextSibling() {
+ let node = this.walker.nextSibling();
+ while (node && this.isSkippedNode(node)) {
+ node = this.walker.nextSibling();
+ }
+ return node;
+ }
+
+ getStartingNode(node, skipTo) {
+ // Keep a reference on the starting node in case we can't find a node compatible with
+ // the filter.
+ const startingNode = node;
+
+ if (skipTo === SKIP_TO_PARENT) {
+ while (node && this.isSkippedNode(node)) {
+ node = node.parentNode;
+ }
+ } else if (skipTo === SKIP_TO_SIBLING) {
+ node = this.getClosestAcceptedSibling(node);
+ }
+
+ return node || startingNode;
+ }
+
+ /**
+ * Loop on all of the provided node siblings until finding one that is compliant with
+ * the filter function.
+ */
+ getClosestAcceptedSibling(node) {
+ if (this.filter(node) === nodeFilterConstants.FILTER_ACCEPT) {
+ // node is already valid, return immediately.
+ return node;
+ }
+
+ // Loop on starting node siblings.
+ let previous = node;
+ let next = node;
+ while (previous || next) {
+ previous = previous?.previousSibling;
+ next = next?.nextSibling;
+
+ if (
+ previous &&
+ this.filter(previous) === nodeFilterConstants.FILTER_ACCEPT
+ ) {
+ // A valid node was found in the previous siblings of the node.
+ return previous;
+ }
+
+ if (next && this.filter(next) === nodeFilterConstants.FILTER_ACCEPT) {
+ // A valid node was found in the next siblings of the node.
+ return next;
+ }
+ }
+
+ return null;
+ }
+
+ isSkippedNode(node) {
+ return this.filter(node) === nodeFilterConstants.FILTER_SKIP;
+ }
+}
+
+exports.DocumentWalker = DocumentWalker;
+exports.SKIP_TO_PARENT = SKIP_TO_PARENT;
+exports.SKIP_TO_SIBLING = SKIP_TO_SIBLING;
diff --git a/devtools/server/actors/inspector/event-collector.js b/devtools/server/actors/inspector/event-collector.js
new file mode 100644
index 0000000000..4ee8dc388f
--- /dev/null
+++ b/devtools/server/actors/inspector/event-collector.js
@@ -0,0 +1,1069 @@
+/* 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/. */
+
+// This file contains event collectors that are then used by developer tools in
+// order to find information about events affecting an HTML element.
+
+"use strict";
+
+const {
+ isAfterPseudoElement,
+ isBeforePseudoElement,
+ isMarkerPseudoElement,
+ isNativeAnonymous,
+} = require("resource://devtools/shared/layout/utils.js");
+const Debugger = require("Debugger");
+const {
+ EXCLUDED_LISTENER,
+} = require("resource://devtools/server/actors/inspector/constants.js");
+
+// eslint-disable-next-line
+const JQUERY_LIVE_REGEX =
+ /return typeof \w+.*.event\.triggered[\s\S]*\.event\.(dispatch|handle).*arguments/;
+
+const REACT_EVENT_NAMES = [
+ "onAbort",
+ "onAnimationEnd",
+ "onAnimationIteration",
+ "onAnimationStart",
+ "onAuxClick",
+ "onBeforeInput",
+ "onBlur",
+ "onCanPlay",
+ "onCanPlayThrough",
+ "onCancel",
+ "onChange",
+ "onClick",
+ "onClose",
+ "onCompositionEnd",
+ "onCompositionStart",
+ "onCompositionUpdate",
+ "onContextMenu",
+ "onCopy",
+ "onCut",
+ "onDoubleClick",
+ "onDrag",
+ "onDragEnd",
+ "onDragEnter",
+ "onDragExit",
+ "onDragLeave",
+ "onDragOver",
+ "onDragStart",
+ "onDrop",
+ "onDurationChange",
+ "onEmptied",
+ "onEncrypted",
+ "onEnded",
+ "onError",
+ "onFocus",
+ "onGotPointerCapture",
+ "onInput",
+ "onInvalid",
+ "onKeyDown",
+ "onKeyPress",
+ "onKeyUp",
+ "onLoad",
+ "onLoadStart",
+ "onLoadedData",
+ "onLoadedMetadata",
+ "onLostPointerCapture",
+ "onMouseDown",
+ "onMouseEnter",
+ "onMouseLeave",
+ "onMouseMove",
+ "onMouseOut",
+ "onMouseOver",
+ "onMouseUp",
+ "onPaste",
+ "onPause",
+ "onPlay",
+ "onPlaying",
+ "onPointerCancel",
+ "onPointerDown",
+ "onPointerEnter",
+ "onPointerLeave",
+ "onPointerMove",
+ "onPointerOut",
+ "onPointerOver",
+ "onPointerUp",
+ "onProgress",
+ "onRateChange",
+ "onReset",
+ "onScroll",
+ "onSeeked",
+ "onSeeking",
+ "onSelect",
+ "onStalled",
+ "onSubmit",
+ "onSuspend",
+ "onTimeUpdate",
+ "onToggle",
+ "onTouchCancel",
+ "onTouchEnd",
+ "onTouchMove",
+ "onTouchStart",
+ "onTransitionEnd",
+ "onVolumeChange",
+ "onWaiting",
+ "onWheel",
+ "onAbortCapture",
+ "onAnimationEndCapture",
+ "onAnimationIterationCapture",
+ "onAnimationStartCapture",
+ "onAuxClickCapture",
+ "onBeforeInputCapture",
+ "onBlurCapture",
+ "onCanPlayCapture",
+ "onCanPlayThroughCapture",
+ "onCancelCapture",
+ "onChangeCapture",
+ "onClickCapture",
+ "onCloseCapture",
+ "onCompositionEndCapture",
+ "onCompositionStartCapture",
+ "onCompositionUpdateCapture",
+ "onContextMenuCapture",
+ "onCopyCapture",
+ "onCutCapture",
+ "onDoubleClickCapture",
+ "onDragCapture",
+ "onDragEndCapture",
+ "onDragEnterCapture",
+ "onDragExitCapture",
+ "onDragLeaveCapture",
+ "onDragOverCapture",
+ "onDragStartCapture",
+ "onDropCapture",
+ "onDurationChangeCapture",
+ "onEmptiedCapture",
+ "onEncryptedCapture",
+ "onEndedCapture",
+ "onErrorCapture",
+ "onFocusCapture",
+ "onGotPointerCaptureCapture",
+ "onInputCapture",
+ "onInvalidCapture",
+ "onKeyDownCapture",
+ "onKeyPressCapture",
+ "onKeyUpCapture",
+ "onLoadCapture",
+ "onLoadStartCapture",
+ "onLoadedDataCapture",
+ "onLoadedMetadataCapture",
+ "onLostPointerCaptureCapture",
+ "onMouseDownCapture",
+ "onMouseEnterCapture",
+ "onMouseLeaveCapture",
+ "onMouseMoveCapture",
+ "onMouseOutCapture",
+ "onMouseOverCapture",
+ "onMouseUpCapture",
+ "onPasteCapture",
+ "onPauseCapture",
+ "onPlayCapture",
+ "onPlayingCapture",
+ "onPointerCancelCapture",
+ "onPointerDownCapture",
+ "onPointerEnterCapture",
+ "onPointerLeaveCapture",
+ "onPointerMoveCapture",
+ "onPointerOutCapture",
+ "onPointerOverCapture",
+ "onPointerUpCapture",
+ "onProgressCapture",
+ "onRateChangeCapture",
+ "onResetCapture",
+ "onScrollCapture",
+ "onSeekedCapture",
+ "onSeekingCapture",
+ "onSelectCapture",
+ "onStalledCapture",
+ "onSubmitCapture",
+ "onSuspendCapture",
+ "onTimeUpdateCapture",
+ "onToggleCapture",
+ "onTouchCancelCapture",
+ "onTouchEndCapture",
+ "onTouchMoveCapture",
+ "onTouchStartCapture",
+ "onTransitionEndCapture",
+ "onVolumeChangeCapture",
+ "onWaitingCapture",
+ "onWheelCapture",
+];
+
+/**
+ * The base class that all the enent collectors should be based upon.
+ */
+class MainEventCollector {
+ /**
+ * We allow displaying chrome events if the page is chrome or if
+ * `devtools.chrome.enabled = true`.
+ */
+ get chromeEnabled() {
+ if (typeof this._chromeEnabled === "undefined") {
+ this._chromeEnabled = Services.prefs.getBoolPref(
+ "devtools.chrome.enabled"
+ );
+ }
+
+ return this._chromeEnabled;
+ }
+
+ /**
+ * Check if a node has any event listeners attached. Please do not override
+ * this method... your getListeners() implementation needs to have the
+ * following signature:
+ * `getListeners(node, {checkOnly} = {})`
+ *
+ * @param {DOMNode} node
+ * The not for which we want to check for event listeners.
+ * @return {Boolean}
+ * true if the node has event listeners, false otherwise.
+ */
+ hasListeners(node) {
+ return this.getListeners(node, {
+ checkOnly: true,
+ });
+ }
+
+ /**
+ * Get all listeners for a node. This method must be overridden.
+ *
+ * @param {DOMNode} node
+ * The not for which we want to get event listeners.
+ * @param {Object} options
+ * An object for passing in options.
+ * @param {Boolean} [options.checkOnly = false]
+ * Don't get any listeners but return true when the first event is
+ * found.
+ * @return {Array}
+ * An array of event handlers.
+ */
+ getListeners(node, { checkOnly }) {
+ throw new Error("You have to implement the method getListeners()!");
+ }
+
+ /**
+ * Get unfiltered DOM Event listeners for a node.
+ * NOTE: These listeners may contain invalid events and events based
+ * on C++ rather than JavaScript.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to get unfiltered event listeners.
+ * @return {Array}
+ * An array of unfiltered event listeners or an empty array
+ */
+ getDOMListeners(node) {
+ let listeners;
+ if (
+ typeof node.nodeName !== "undefined" &&
+ node.nodeName.toLowerCase() === "html"
+ ) {
+ const winListeners =
+ Services.els.getListenerInfoFor(node.ownerGlobal) || [];
+ const docElementListeners = Services.els.getListenerInfoFor(node) || [];
+ const docListeners =
+ Services.els.getListenerInfoFor(node.parentNode) || [];
+
+ listeners = [...winListeners, ...docElementListeners, ...docListeners];
+ } else {
+ listeners = Services.els.getListenerInfoFor(node) || [];
+ }
+
+ return listeners.filter(listener => {
+ const obj = this.unwrap(listener.listenerObject);
+ return !obj || !obj[EXCLUDED_LISTENER];
+ });
+ }
+
+ getJQuery(node) {
+ if (Cu.isDeadWrapper(node)) {
+ return null;
+ }
+
+ const global = this.unwrap(node.ownerGlobal);
+ if (!global) {
+ return null;
+ }
+
+ const hasJQuery = global.jQuery?.fn?.jquery;
+
+ if (hasJQuery) {
+ return global.jQuery;
+ }
+ return null;
+ }
+
+ unwrap(obj) {
+ return Cu.isXrayWrapper(obj) ? obj.wrappedJSObject : obj;
+ }
+
+ isChromeHandler(handler) {
+ try {
+ const handlerPrincipal = Cu.getObjectPrincipal(handler);
+
+ // Chrome codebase may register listeners on the page from a frame script or
+ // JSM <video> tags may also report internal listeners, but they won't be
+ // coming from the system principal. Instead, they will be using an expanded
+ // principal.
+ return (
+ handlerPrincipal.isSystemPrincipal ||
+ handlerPrincipal.isExpandedPrincipal
+ );
+ } catch (e) {
+ // Anything from a dead object to a CSP error can leave us here so let's
+ // return false so that we can fail gracefully.
+ return false;
+ }
+ }
+}
+
+/**
+ * Get or detect DOM events. These may include DOM events created by libraries
+ * that enable their custom events to work. At this point we are unable to
+ * effectively filter them as they may be proxied or wrapped. Although we know
+ * there is an event, we may not know the true contents until it goes
+ * through `processHandlerForEvent()`.
+ */
+class DOMEventCollector extends MainEventCollector {
+ getListeners(node, { checkOnly } = {}) {
+ const handlers = [];
+ const listeners = this.getDOMListeners(node);
+
+ for (const listener of listeners) {
+ // Ignore listeners without a type, e.g.
+ // node.addEventListener("", function() {})
+ if (!listener.type) {
+ continue;
+ }
+
+ // Get the listener object, either a Function or an Object.
+ const obj = listener.listenerObject;
+
+ // Ignore listeners without any listener, e.g.
+ // node.addEventListener("mouseover", null);
+ if (!obj) {
+ continue;
+ }
+
+ let handler = null;
+
+ // An object without a valid handleEvent is not a valid listener.
+ if (typeof obj === "object") {
+ const unwrapped = this.unwrap(obj);
+ if (typeof unwrapped.handleEvent === "function") {
+ handler = Cu.unwaiveXrays(unwrapped.handleEvent);
+ }
+ } else if (typeof obj === "function") {
+ // Ignore DOM events used to trigger jQuery events as they are only
+ // useful to the developers of the jQuery library.
+ if (JQUERY_LIVE_REGEX.test(obj.toString())) {
+ continue;
+ }
+ // Otherwise, the other valid listener type is function.
+ handler = obj;
+ }
+
+ // Ignore listeners that have no handler.
+ if (!handler) {
+ continue;
+ }
+
+ // If we shouldn't be showing chrome events due to context and this is a
+ // chrome handler we can ignore it.
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ // If this is checking if a node has any listeners then we have found one
+ // so return now.
+ if (checkOnly) {
+ return true;
+ }
+
+ const eventInfo = {
+ nsIEventListenerInfo: listener,
+ capturing: listener.capturing,
+ type: listener.type,
+ handler,
+ enabled: listener.enabled,
+ };
+
+ handlers.push(eventInfo);
+ }
+
+ // If this is checking if a node has any listeners then none were found so
+ // return false.
+ if (checkOnly) {
+ return false;
+ }
+
+ return handlers;
+ }
+}
+
+/**
+ * Get or detect jQuery events.
+ */
+class JQueryEventCollector extends MainEventCollector {
+ // eslint-disable-next-line complexity
+ getListeners(node, { checkOnly } = {}) {
+ const jQuery = this.getJQuery(node);
+ const handlers = [];
+
+ // If jQuery is not on the page, if this is an anonymous node or a pseudo
+ // element we need to return early.
+ if (
+ !jQuery ||
+ isNativeAnonymous(node) ||
+ isMarkerPseudoElement(node) ||
+ isBeforePseudoElement(node) ||
+ isAfterPseudoElement(node)
+ ) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ let eventsObj = null;
+ const data = jQuery._data || jQuery.data;
+
+ if (data) {
+ // jQuery 1.2+
+ try {
+ eventsObj = data(node, "events");
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+ } else {
+ // JQuery 1.0 & 1.1
+ let entry;
+ try {
+ entry = entry = jQuery(node)[0];
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+
+ if (!entry || !entry.events) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ eventsObj = entry.events;
+ }
+
+ if (eventsObj) {
+ for (const [type, events] of Object.entries(eventsObj)) {
+ for (const [, event] of Object.entries(events)) {
+ // Skip events that are part of jQueries internals.
+ if (node.nodeType == node.DOCUMENT_NODE && event.selector) {
+ continue;
+ }
+
+ if (typeof event === "function" || typeof event === "object") {
+ // If we shouldn't be showing chrome events due to context and this
+ // is a chrome handler we can ignore it.
+ const handler = event.handler || event;
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+
+ const eventInfo = {
+ type,
+ handler,
+ tags: "jQuery",
+ hide: {
+ capturing: true,
+ },
+ };
+
+ handlers.push(eventInfo);
+ }
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+}
+
+/**
+ * Get or detect jQuery live events.
+ */
+class JQueryLiveEventCollector extends MainEventCollector {
+ // eslint-disable-next-line complexity
+ getListeners(node, { checkOnly } = {}) {
+ const jQuery = this.getJQuery(node);
+ const handlers = [];
+
+ if (!jQuery) {
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ const data = jQuery._data || jQuery.data;
+
+ if (data) {
+ // Live events are added to the document and bubble up to all elements.
+ // Any element matching the specified selector will trigger the live
+ // event.
+ const win = this.unwrap(node.ownerGlobal);
+ let events = null;
+
+ try {
+ events = data(win.document, "events");
+ } catch (e) {
+ // We have no access to a JS object. This is probably due to a CORS
+ // violation. Using try / catch is the only way to avoid this error.
+ }
+
+ if (events) {
+ for (const [, eventHolder] of Object.entries(events)) {
+ for (const [idx, event] of Object.entries(eventHolder)) {
+ if (typeof idx !== "string" || isNaN(parseInt(idx, 10))) {
+ continue;
+ }
+
+ let selector = event.selector;
+
+ if (!selector && event.data) {
+ selector = event.data.selector || event.data || event.selector;
+ }
+
+ if (!selector || !node.ownerDocument) {
+ continue;
+ }
+
+ let matches;
+ try {
+ matches = node.matches && node.matches(selector);
+ } catch (e) {
+ // Invalid selector, do nothing.
+ }
+
+ if (!matches) {
+ continue;
+ }
+
+ if (typeof event === "function" || typeof event === "object") {
+ // If we shouldn't be showing chrome events due to context and this
+ // is a chrome handler we can ignore it.
+ const handler = event.handler || event;
+ if (!this.chromeEnabled && this.isChromeHandler(handler)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+ const eventInfo = {
+ type: event.origType || event.type.substr(selector.length + 1),
+ handler,
+ tags: "jQuery,Live",
+ hide: {
+ capturing: true,
+ },
+ };
+
+ if (!eventInfo.type && event.data?.live) {
+ eventInfo.type = event.data.live;
+ }
+
+ handlers.push(eventInfo);
+ }
+ }
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+ return handlers;
+ }
+
+ normalizeListener(handlerDO) {
+ function isFunctionInProxy(funcDO) {
+ // If the anonymous function is inside the |proxy| function and the
+ // function only has guessed atom, the guessed atom should starts with
+ // "proxy/".
+ const displayName = funcDO.displayName;
+ if (displayName && displayName.startsWith("proxy/")) {
+ return true;
+ }
+
+ // If the anonymous function is inside the |proxy| function and the
+ // function gets name at compile time by SetFunctionName, its guessed
+ // atom doesn't contain "proxy/". In that case, check if the caller is
+ // "proxy" function, as a fallback.
+ const calleeDS = funcDO.environment?.calleeScript;
+ if (!calleeDS) {
+ return false;
+ }
+ const calleeName = calleeDS.displayName;
+ return calleeName == "proxy";
+ }
+
+ function getFirstFunctionVariable(funcDO) {
+ // The handler function inside the |proxy| function should point the
+ // unwrapped function via environment variable.
+ const names = funcDO.environment ? funcDO.environment.names() : [];
+ for (const varName of names) {
+ const varDO = handlerDO.environment
+ ? handlerDO.environment.getVariable(varName)
+ : null;
+ if (!varDO) {
+ continue;
+ }
+ if (varDO.class == "Function") {
+ return varDO;
+ }
+ }
+ return null;
+ }
+
+ if (!isFunctionInProxy(handlerDO)) {
+ return handlerDO;
+ }
+
+ const MAX_NESTED_HANDLER_COUNT = 2;
+ for (let i = 0; i < MAX_NESTED_HANDLER_COUNT; i++) {
+ const funcDO = getFirstFunctionVariable(handlerDO);
+ if (!funcDO) {
+ return handlerDO;
+ }
+
+ handlerDO = funcDO;
+ if (isFunctionInProxy(handlerDO)) {
+ continue;
+ }
+ break;
+ }
+
+ return handlerDO;
+ }
+}
+
+/**
+ * Get or detect React events.
+ */
+class ReactEventCollector extends MainEventCollector {
+ getListeners(node, { checkOnly } = {}) {
+ const handlers = [];
+ const props = this.getProps(node);
+
+ if (props) {
+ for (const [name, prop] of Object.entries(props)) {
+ if (REACT_EVENT_NAMES.includes(name)) {
+ const listener = prop?.__reactBoundMethod || prop;
+
+ if (typeof listener !== "function") {
+ continue;
+ }
+
+ if (!this.chromeEnabled && this.isChromeHandler(listener)) {
+ continue;
+ }
+
+ if (checkOnly) {
+ return true;
+ }
+
+ const handler = {
+ type: name,
+ handler: listener,
+ tags: "React",
+ override: {
+ capturing: name.endsWith("Capture"),
+ },
+ };
+
+ handlers.push(handler);
+ }
+ }
+ }
+
+ if (checkOnly) {
+ return false;
+ }
+
+ return handlers;
+ }
+
+ getProps(node) {
+ node = this.unwrap(node);
+
+ for (const key of Object.keys(node)) {
+ if (key.startsWith("__reactInternalInstance$")) {
+ const value = node[key];
+ if (value.memoizedProps) {
+ return value.memoizedProps; // React 16
+ }
+ return value?._currentElement?.props; // React 15
+ }
+ }
+ return null;
+ }
+
+ normalizeListener(handlerDO, listener) {
+ let functionText = "";
+
+ if (handlerDO.boundTargetFunction) {
+ handlerDO = handlerDO.boundTargetFunction;
+ }
+
+ const script = handlerDO.script;
+ // Script might be undefined (eg for methods bound several times, see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1589658)
+ const introScript = script?.source.introductionScript;
+
+ // If this is a Babel transpiled function we have no access to the
+ // source location so we need to hide the filename and debugger
+ // icon.
+ if (introScript && introScript.displayName.endsWith("/transform.run")) {
+ listener.hide.debugger = true;
+ listener.hide.filename = true;
+
+ if (!handlerDO.isArrowFunction) {
+ functionText += "function (";
+ } else {
+ functionText += "(";
+ }
+
+ functionText += handlerDO.parameterNames.join(", ");
+
+ functionText += ") {\n";
+
+ const scriptSource = script.source.text;
+ functionText += scriptSource.substr(
+ script.sourceStart,
+ script.sourceLength
+ );
+
+ listener.override.handler = functionText;
+ }
+
+ return handlerDO;
+ }
+}
+
+/**
+ * The exposed class responsible for gathering events.
+ */
+class EventCollector {
+ constructor(targetActor) {
+ this.targetActor = targetActor;
+
+ // The event collector array. Please preserve the order otherwise there will
+ // be multiple failing tests.
+ this.eventCollectors = [
+ new ReactEventCollector(),
+ new JQueryLiveEventCollector(),
+ new JQueryEventCollector(),
+ new DOMEventCollector(),
+ ];
+ }
+
+ /**
+ * Destructor (must be called manually).
+ */
+ destroy() {
+ this.eventCollectors = null;
+ }
+
+ /**
+ * Iterate through all event collectors returning on the first found event.
+ *
+ * @param {DOMNode} node
+ * The node to be checked for events.
+ * @return {Boolean}
+ * True if the node has event listeners, false otherwise.
+ */
+ hasEventListeners(node) {
+ for (const collector of this.eventCollectors) {
+ if (collector.hasListeners(node)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * We allow displaying chrome events if the page is chrome or if
+ * `devtools.chrome.enabled = true`.
+ */
+ get chromeEnabled() {
+ if (typeof this._chromeEnabled === "undefined") {
+ this._chromeEnabled = Services.prefs.getBoolPref(
+ "devtools.chrome.enabled"
+ );
+ }
+
+ return this._chromeEnabled;
+ }
+
+ /**
+ *
+ * @param {DOMNode} node
+ * The node for which events are to be gathered.
+ * @return {Array<Object>}
+ * An array containing objects in the following format:
+ * {
+ * {String} type: The event type, e.g. "click"
+ * {Function} handler: The function called when event is triggered.
+ * {Boolean} enabled: Whether the listener is enabled or not (event listeners can
+ * be disabled via the inspector)
+ * {String} tags: Comma separated list of tags displayed inside event bubble (e.g. "JQuery")
+ * {Object} hide: Flags for hiding certain properties.
+ * {Boolean} capturing
+ * }
+ * {Boolean} native
+ * {String|undefined} sourceActor: The sourceActor id of the event listener
+ * {nsIEventListenerInfo|undefined} nsIEventListenerInfo
+ * }
+ */
+ getEventListeners(node) {
+ const listenerArray = [];
+ let dbg;
+ if (!this.chromeEnabled) {
+ dbg = new Debugger();
+ } else {
+ // When the chrome pref is turned on, we may try to debug system compartments.
+ // But since bug 1517210, the server is also loaded using the system principal
+ // and so here, we have to ensure using a special Debugger instance, loaded
+ // in a compartment flagged with invisibleToDebugger=true. This helps the Debugger
+ // know about the precise boundary between debuggee and debugger code.
+ const ChromeDebugger = require("ChromeDebugger");
+ dbg = new ChromeDebugger();
+ }
+
+ for (const collector of this.eventCollectors) {
+ const listeners = collector.getListeners(node);
+
+ if (!listeners) {
+ continue;
+ }
+
+ for (const listener of listeners) {
+ const eventObj = this.processHandlerForEvent(
+ listener,
+ dbg,
+ collector.normalizeListener
+ );
+ if (eventObj) {
+ listenerArray.push(eventObj);
+ }
+ }
+ }
+
+ listenerArray.sort((a, b) => {
+ return a.type.localeCompare(b.type);
+ });
+
+ return listenerArray;
+ }
+
+ /**
+ * Process an event listener.
+ *
+ * @param {EventListener} listener
+ * The event listener to process.
+ * @param {Debugger} dbg
+ * Debugger instance.
+ * @param {Function|null} normalizeListener
+ * An optional function that will be called to retrieve data about the listener.
+ * It should be a *Collector method.
+ *
+ * @return {Array}
+ * An array of objects where a typical object looks like this:
+ * {
+ * type: "click",
+ * handler: function() { doSomething() },
+ * origin: "http://www.mozilla.com",
+ * tags: tags,
+ * capturing: true,
+ * hide: {
+ * capturing: true
+ * },
+ * native: false,
+ * enabled: true
+ * sourceActor: "sourceActor.1234",
+ * nsIEventListenerInfo: nsIEventListenerInfo {…},
+ * }
+ */
+ // eslint-disable-next-line complexity
+ processHandlerForEvent(listener, dbg, normalizeListener) {
+ let globalDO;
+ let eventObj;
+
+ try {
+ const { capturing, handler } = listener;
+
+ const global = Cu.getGlobalForObject(handler);
+
+ // It is important that we recreate the globalDO for each handler because
+ // their global object can vary e.g. resource:// URLs on a video control. If
+ // we don't do this then all chrome listeners simply display "native code."
+ globalDO = dbg.addDebuggee(global);
+ let listenerDO = globalDO.makeDebuggeeValue(handler);
+
+ if (normalizeListener) {
+ listenerDO = normalizeListener(listenerDO, listener);
+ }
+
+ const hide = listener.hide || {};
+ const override = listener.override || {};
+ const tags = listener.tags || "";
+ const type = listener.type || "";
+ const enabled = !!listener.enabled;
+ let functionSource = handler.toString();
+ let line = 0;
+ let column = null;
+ let native = false;
+ let url = "";
+ let sourceActor = "";
+
+ // If the listener is an object with a 'handleEvent' method, use that.
+ if (
+ listenerDO.class === "Object" ||
+ /^XUL\w*Element$/.test(listenerDO.class)
+ ) {
+ let desc;
+
+ while (!desc && listenerDO) {
+ desc = listenerDO.getOwnPropertyDescriptor("handleEvent");
+ listenerDO = listenerDO.proto;
+ }
+
+ if (desc?.value) {
+ listenerDO = desc.value;
+ }
+ }
+
+ // If the listener is bound to a different context then we need to switch
+ // to the bound function.
+ if (listenerDO.isBoundFunction) {
+ listenerDO = listenerDO.boundTargetFunction;
+ }
+
+ const { isArrowFunction, name, script, parameterNames } = listenerDO;
+
+ if (script) {
+ const scriptSource = script.source.text;
+
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ line = script.startLine;
+ column = script.startColumn - columnBase;
+ url = script.url;
+ const actor = this.targetActor.sourcesManager.getOrCreateSourceActor(
+ script.source
+ );
+ sourceActor = actor ? actor.actorID : null;
+
+ // Checking for the string "[native code]" is the only way at this point
+ // to check for native code. Even if this provides a false positive then
+ // grabbing the source code a second time is harmless.
+ if (
+ functionSource === "[object Object]" ||
+ functionSource === "[object XULElement]" ||
+ functionSource.includes("[native code]")
+ ) {
+ functionSource = scriptSource.substr(
+ script.sourceStart,
+ script.sourceLength
+ );
+
+ // At this point the script looks like this:
+ // () { ... }
+ // We prefix this with "function" if it is not a fat arrow function.
+ if (!isArrowFunction) {
+ functionSource = "function " + functionSource;
+ }
+ }
+ } else {
+ // If the listener is a native one (provided by C++ code) then we have no
+ // access to the script. We use the native flag to prevent showing the
+ // debugger button because the script is not available.
+ native = true;
+ }
+
+ // Arrow function text always contains the parameters. Function
+ // parameters are often missing e.g. if Array.sort is used as a handler.
+ // If they are missing we provide the parameters ourselves.
+ if (parameterNames && parameterNames.length) {
+ const prefix = "function " + name + "()";
+ const paramString = parameterNames.join(", ");
+
+ if (functionSource.startsWith(prefix)) {
+ functionSource = functionSource.substr(prefix.length);
+
+ functionSource = `function ${name} (${paramString})${functionSource}`;
+ }
+ }
+
+ // If the listener is native code we display the filename "[native code]."
+ // This is the official string and should *not* be translated.
+ let origin;
+ if (native) {
+ origin = "[native code]";
+ } else {
+ origin =
+ url +
+ (line ? ":" + line + (column === null ? "" : ":" + column) : "");
+ }
+
+ eventObj = {
+ type: override.type || type,
+ handler: override.handler || functionSource.trim(),
+ origin: override.origin || origin,
+ tags: override.tags || tags,
+ capturing:
+ typeof override.capturing !== "undefined"
+ ? override.capturing
+ : capturing,
+ hide: typeof override.hide !== "undefined" ? override.hide : hide,
+ native,
+ sourceActor,
+ nsIEventListenerInfo: listener.nsIEventListenerInfo,
+ enabled,
+ };
+
+ // Hide the debugger icon for DOM0 and native listeners. DOM0 listeners are
+ // generated dynamically from e.g. an onclick="" attribute so the script
+ // doesn't actually exist.
+ if (!sourceActor) {
+ eventObj.hide.debugger = true;
+ }
+ } finally {
+ // Ensure that we always remove the debuggee.
+ if (globalDO) {
+ dbg.removeDebuggee(globalDO);
+ }
+ }
+
+ return eventObj;
+ }
+}
+
+exports.EventCollector = EventCollector;
diff --git a/devtools/server/actors/inspector/inspector.js b/devtools/server/actors/inspector/inspector.js
new file mode 100644
index 0000000000..cdfa892889
--- /dev/null
+++ b/devtools/server/actors/inspector/inspector.js
@@ -0,0 +1,355 @@
+/* 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";
+
+/**
+ * Here's the server side of the remote inspector.
+ *
+ * The WalkerActor is the client's view of the debuggee's DOM. It's gives
+ * the client a tree of NodeActor objects.
+ *
+ * The walker presents the DOM tree mostly unmodified from the source DOM
+ * tree, but with a few key differences:
+ *
+ * - Empty text nodes are ignored. This is pretty typical of developer
+ * tools, but maybe we should reconsider that on the server side.
+ * - iframes with documents loaded have the loaded document as the child,
+ * the walker provides one big tree for the whole document tree.
+ *
+ * There are a few ways to get references to NodeActors:
+ *
+ * - When you first get a WalkerActor reference, it comes with a free
+ * reference to the root document's node.
+ * - Given a node, you can ask for children, siblings, and parents.
+ * - You can issue querySelector and querySelectorAll requests to find
+ * other elements.
+ * - Requests that return arbitrary nodes from the tree (like querySelector
+ * and querySelectorAll) will also return any nodes the client hasn't
+ * seen in order to have a complete set of parents.
+ *
+ * Once you have a NodeFront, you should be able to answer a few questions
+ * without further round trips, like the node's name, namespace/tagName,
+ * attributes, etc. Other questions (like a text node's full nodeValue)
+ * might require another round trip.
+ *
+ * The protocol guarantees that the client will always know the parent of
+ * any node that is returned by the server. This means that some requests
+ * (like querySelector) will include the extra nodes needed to satisfy this
+ * requirement. The client keeps track of this parent relationship, so the
+ * node fronts form a tree that is a subset of the actual DOM tree.
+ *
+ *
+ * We maintain this guarantee to support the ability to release subtrees on
+ * the client - when a node is disconnected from the DOM tree we want to be
+ * able to free the client objects for all the children nodes.
+ *
+ * So to be able to answer "all the children of a given node that we have
+ * seen on the client side", we guarantee that every time we've seen a node,
+ * we connect it up through its parents.
+ */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ inspectorSpec,
+} = require("resource://devtools/shared/specs/inspector.js");
+
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+loader.lazyRequireGetter(
+ this,
+ "InspectorActorUtils",
+ "resource://devtools/server/actors/inspector/utils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "WalkerActor",
+ "resource://devtools/server/actors/inspector/walker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EyeDropper",
+ "resource://devtools/server/actors/highlighters/eye-dropper.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PageStyleActor",
+ "resource://devtools/server/actors/page-style.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CustomHighlighterActor", "isTypeRegistered", "HighlighterEnvironment"],
+ "resource://devtools/server/actors/highlighters.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "CompatibilityActor",
+ "resource://devtools/server/actors/compatibility/compatibility.js",
+ true
+);
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/**
+ * Server side of the inspector actor, which is used to create
+ * inspector-related actors, including the walker.
+ */
+class InspectorActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, inspectorSpec);
+ this.targetActor = targetActor;
+
+ this._onColorPicked = this._onColorPicked.bind(this);
+ this._onColorPickCanceled = this._onColorPickCanceled.bind(this);
+ this.destroyEyeDropper = this.destroyEyeDropper.bind(this);
+ }
+
+ destroy() {
+ super.destroy();
+ this.destroyEyeDropper();
+
+ this._compatibility = null;
+ this._pageStylePromise = null;
+ this._walkerPromise = null;
+ this.walker = null;
+ this.targetActor = null;
+ }
+
+ get window() {
+ return this.targetActor.window;
+ }
+
+ getWalker(options = {}) {
+ if (this._walkerPromise) {
+ return this._walkerPromise;
+ }
+
+ this._walkerPromise = new Promise(resolve => {
+ const domReady = () => {
+ const targetActor = this.targetActor;
+ this.walker = new WalkerActor(this.conn, targetActor, options);
+ this.manage(this.walker);
+ this.walker.once("destroyed", () => {
+ this._walkerPromise = null;
+ this._pageStylePromise = null;
+ });
+ resolve(this.walker);
+ };
+
+ if (this.window.document.readyState === "loading") {
+ // Expose an abort controller for DOMContentLoaded to remove the
+ // listener unconditionally, even if the race hits the timeout.
+ const abortController = new AbortController();
+ Promise.race([
+ new Promise(r => {
+ this.window.addEventListener("DOMContentLoaded", r, {
+ capture: true,
+ once: true,
+ signal: abortController.signal,
+ });
+ }),
+ // The DOMContentLoaded event will never be emitted on documents stuck
+ // in the loading state, for instance if document.write was called
+ // without calling document.close.
+ // TODO: It is not clear why we are waiting for the event overall, see
+ // Bug 1766279 to actually stop listening to the event altogether.
+ new Promise(r => setTimeout(r, 500)),
+ ])
+ .then(domReady)
+ .finally(() => abortController.abort());
+ } else {
+ domReady();
+ }
+ });
+
+ return this._walkerPromise;
+ }
+
+ getPageStyle() {
+ if (this._pageStylePromise) {
+ return this._pageStylePromise;
+ }
+
+ this._pageStylePromise = this.getWalker().then(walker => {
+ const pageStyle = new PageStyleActor(this);
+ this.manage(pageStyle);
+ return pageStyle;
+ });
+ return this._pageStylePromise;
+ }
+
+ getCompatibility() {
+ if (this._compatibility) {
+ return this._compatibility;
+ }
+
+ this._compatibility = new CompatibilityActor(this);
+ this.manage(this._compatibility);
+ return this._compatibility;
+ }
+
+ /**
+ * If consumers need to display several highlighters at the same time or
+ * different types of highlighters, then this method should be used, passing
+ * the type name of the highlighter needed as argument.
+ * A new instance will be created everytime the method is called, so it's up
+ * to the consumer to release it when it is not needed anymore
+ *
+ * @param {String} type The type of highlighter to create
+ * @return {Highlighter} The highlighter actor instance or null if the
+ * typeName passed doesn't match any available highlighter
+ */
+ async getHighlighterByType(typeName) {
+ if (isTypeRegistered(typeName)) {
+ const highlighterActor = new CustomHighlighterActor(this, typeName);
+ if (highlighterActor.instance.isReady) {
+ await highlighterActor.instance.isReady;
+ }
+
+ return highlighterActor;
+ }
+ return null;
+ }
+
+ /**
+ * Get the node's image data if any (for canvas and img nodes).
+ * Returns an imageData object with the actual data being a LongStringActor
+ * and a size json object.
+ * The image data is transmitted as a base64 encoded png data-uri.
+ * The method rejects if the node isn't an image or if the image is missing
+ *
+ * Accepts a maxDim request parameter to resize images that are larger. This
+ * is important as the resizing occurs server-side so that image-data being
+ * transfered in the longstring back to the client will be that much smaller
+ */
+ getImageDataFromURL(url, maxDim) {
+ const img = new this.window.Image();
+ img.src = url;
+
+ // imageToImageData waits for the image to load.
+ return InspectorActorUtils.imageToImageData(img, maxDim).then(imageData => {
+ return {
+ data: new LongStringActor(this.conn, imageData.data),
+ size: imageData.size,
+ };
+ });
+ }
+
+ /**
+ * Resolve a URL to its absolute form, in the scope of a given content window.
+ * @param {String} url.
+ * @param {NodeActor} node If provided, the owner window of this node will be
+ * used to resolve the URL. Otherwise, the top-level content window will be
+ * used instead.
+ * @return {String} url.
+ */
+ resolveRelativeURL(url, node) {
+ const document = InspectorActorUtils.isNodeDead(node)
+ ? this.window.document
+ : InspectorActorUtils.nodeDocument(node.rawNode);
+
+ if (!document) {
+ return url;
+ }
+
+ const baseURI = Services.io.newURI(document.location.href);
+ return Services.io.newURI(url, null, baseURI).spec;
+ }
+
+ /**
+ * Create an instance of the eye-dropper highlighter and store it on this._eyeDropper.
+ * Note that for now, a new instance is created every time to deal with page navigation.
+ */
+ createEyeDropper() {
+ this.destroyEyeDropper();
+ this._highlighterEnv = new HighlighterEnvironment();
+ this._highlighterEnv.initFromTargetActor(this.targetActor);
+ this._eyeDropper = new EyeDropper(this._highlighterEnv);
+ return this._eyeDropper.isReady;
+ }
+
+ /**
+ * Destroy the current eye-dropper highlighter instance.
+ */
+ destroyEyeDropper() {
+ if (this._eyeDropper) {
+ this.cancelPickColorFromPage();
+ this._eyeDropper.destroy();
+ this._eyeDropper = null;
+ this._highlighterEnv.destroy();
+ this._highlighterEnv = null;
+ }
+ }
+
+ /**
+ * Pick a color from the page using the eye-dropper. This method doesn't return anything
+ * but will cause events to be sent to the front when a color is picked or when the user
+ * cancels the picker.
+ * @param {Object} options
+ */
+ async pickColorFromPage(options) {
+ await this.createEyeDropper();
+ this._eyeDropper.show(this.window.document.documentElement, options);
+ this._eyeDropper.once("selected", this._onColorPicked);
+ this._eyeDropper.once("canceled", this._onColorPickCanceled);
+ this.targetActor.once("will-navigate", this.destroyEyeDropper);
+ }
+
+ /**
+ * After the pickColorFromPage method is called, the only way to dismiss the eye-dropper
+ * highlighter is for the user to click in the page and select a color. If you need to
+ * dismiss the eye-dropper programatically instead, use this method.
+ */
+ cancelPickColorFromPage() {
+ if (this._eyeDropper) {
+ this._eyeDropper.hide();
+ this._eyeDropper.off("selected", this._onColorPicked);
+ this._eyeDropper.off("canceled", this._onColorPickCanceled);
+ this.targetActor.off("will-navigate", this.destroyEyeDropper);
+ }
+ }
+
+ /**
+ * Check if the current document supports highlighters using a canvasFrame anonymous
+ * content container.
+ * It is impossible to detect the feature programmatically as some document types simply
+ * don't render the canvasFrame without throwing any error.
+ */
+ supportsHighlighters() {
+ const doc = this.targetActor.window.document;
+ const ns = doc.documentElement.namespaceURI;
+
+ // XUL documents do not support insertAnonymousContent().
+ if (ns === XUL_NS) {
+ return false;
+ }
+
+ // SVG documents do not render the canvasFrame (see Bug 1157592).
+ if (ns === SVG_NS) {
+ return false;
+ }
+
+ return true;
+ }
+
+ _onColorPicked(color) {
+ this.emit("color-picked", color);
+ }
+
+ _onColorPickCanceled() {
+ this.emit("color-pick-canceled");
+ }
+}
+
+exports.InspectorActor = InspectorActor;
diff --git a/devtools/server/actors/inspector/moz.build b/devtools/server/actors/inspector/moz.build
new file mode 100644
index 0000000000..03c69dc9fe
--- /dev/null
+++ b/devtools/server/actors/inspector/moz.build
@@ -0,0 +1,21 @@
+# -*- 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(
+ "constants.js",
+ "css-logic.js",
+ "custom-element-watcher.js",
+ "document-walker.js",
+ "event-collector.js",
+ "inspector.js",
+ "node-picker.js",
+ "node.js",
+ "utils.js",
+ "walker.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Inspector")
diff --git a/devtools/server/actors/inspector/node-picker.js b/devtools/server/actors/inspector/node-picker.js
new file mode 100644
index 0000000000..4e090959c9
--- /dev/null
+++ b/devtools/server/actors/inspector/node-picker.js
@@ -0,0 +1,435 @@
+/* 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,
+ "isRemoteBrowserElement",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "HighlighterEnvironment",
+ "resource://devtools/server/actors/highlighters.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "RemoteNodePickerNotice",
+ "resource://devtools/server/actors/highlighters/remote-node-picker-notice.js",
+ true
+);
+
+const IS_OSX = Services.appinfo.OS === "Darwin";
+
+class NodePicker {
+ #eventListenersAbortController;
+ #remoteNodePickerNoticeHighlighter;
+
+ constructor(walker, targetActor) {
+ this._walker = walker;
+ this._targetActor = targetActor;
+
+ this._isPicking = false;
+ this._hoveredNode = null;
+ this._currentNode = null;
+
+ this._onHovered = this._onHovered.bind(this);
+ this._onKey = this._onKey.bind(this);
+ this._onPick = this._onPick.bind(this);
+ this._onSuppressedEvent = this._onSuppressedEvent.bind(this);
+ this._preventContentEvent = this._preventContentEvent.bind(this);
+ }
+
+ get remoteNodePickerNoticeHighlighter() {
+ if (!this.#remoteNodePickerNoticeHighlighter) {
+ const env = new HighlighterEnvironment();
+ env.initFromTargetActor(this._targetActor);
+ this.#remoteNodePickerNoticeHighlighter = new RemoteNodePickerNotice(env);
+ }
+
+ return this.#remoteNodePickerNoticeHighlighter;
+ }
+
+ _findAndAttachElement(event) {
+ // originalTarget allows access to the "real" element before any retargeting
+ // is applied, such as in the case of XBL anonymous elements. See also
+ // https://developer.mozilla.org/docs/XBL/XBL_1.0_Reference/Anonymous_Content#Event_Flow_and_Targeting
+ let node = event.originalTarget || event.target;
+
+ // When holding the Shift key, search for the element at the mouse position (as opposed
+ // to the event target). This would make it possible to pick nodes for which we won't
+ // get events for (e.g. elements with `pointer-events: none`).
+ if (event.shiftKey) {
+ node = this._findNodeAtMouseEventPosition(event) || node;
+ }
+
+ return this._walker.attachElement(node);
+ }
+
+ /**
+ * Return the topmost visible element located at the event mouse position. This is
+ * different from retrieving the event target as it allows to retrieve elements for which
+ * we wouldn't have mouse event triggered (e.g. elements with `pointer-events: none`)
+ *
+ * @param {MouseEvent} event
+ * @returns HTMLElement
+ */
+ _findNodeAtMouseEventPosition(event) {
+ const winUtils = this._targetActor.window.windowUtils;
+ const rectSize = 1;
+ const elements = winUtils.nodesFromRect(
+ // aX
+ event.clientX,
+ // aY
+ event.clientY,
+ // aTopSize
+ rectSize,
+ // aRightSize
+ rectSize,
+ // aBottomSize
+ rectSize,
+ // aLeftSize
+ rectSize,
+ // aIgnoreRootScrollFrame
+ true,
+ // aFlushLayout
+ false,
+ // aOnlyVisible
+ true,
+ // aTransparencyThreshold
+ 1
+ );
+
+ // ⚠️ When a highlighter was added to the page (which is the case at this point),
+ // the first element is the html node, and might be the last one as well (See Bug 1744941).
+ // Until we figure this out, let's pick the second returned item when hit this.
+ if (
+ elements.length > 1 &&
+ ChromeUtils.getClassName(elements[0]) == "HTMLHtmlElement"
+ ) {
+ return elements[1];
+ }
+
+ return elements[0];
+ }
+
+ /**
+ * Returns `true` if the event was dispatched from a window included in
+ * the current highlighter environment; or if the highlighter environment has
+ * chrome privileges
+ *
+ * @param {Event} event
+ * The event to allow
+ * @return {Boolean}
+ */
+ _isEventAllowed({ view }) {
+ // Allow "non multiprocess" browser toolbox to inspect documents loaded in the parent
+ // process (e.g. about:robots)
+ if (this._targetActor.window.isChromeWindow) {
+ return true;
+ }
+
+ return this._targetActor.windows.includes(view);
+ }
+
+ /**
+ * Returns true if the passed event original target is in the RemoteNodePickerNotice.
+ *
+ * @param {Event} event
+ * @returns {Boolean}
+ */
+ _isEventInRemoteNodePickerNotice(event) {
+ return (
+ this.#remoteNodePickerNoticeHighlighter &&
+ event.originalTarget?.closest?.(
+ `#${this.#remoteNodePickerNoticeHighlighter.rootElementId}`
+ )
+ );
+ }
+
+ /**
+ * Pick a node on click.
+ *
+ * This method doesn't respond anything interesting, however, it starts
+ * mousemove, and click listeners on the content document to fire
+ * events and let connected clients know when nodes are hovered over or
+ * clicked.
+ *
+ * Once a node is picked, events will cease, and listeners will be removed.
+ */
+ _onPick(event) {
+ // If the picked node is a remote frame, then we need to let the event through
+ // since there's a highlighter actor in that sub-frame also picking.
+ if (isRemoteBrowserElement(event.target)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ // If the click was done inside the node picker notice highlighter (e.g. clicking the
+ // close button), directly call its `onClick` method, as it doesn't have event listeners
+ // itself, to avoid managing events (+ suppressedEventListeners) for the same target
+ // from different places.
+ if (this._isEventInRemoteNodePickerNotice(event)) {
+ this.#remoteNodePickerNoticeHighlighter.onClick(event);
+ return;
+ }
+
+ // If Ctrl (Or Cmd on OSX) is pressed, this is only a preview click.
+ // Send the event to the client, but don't stop picking.
+ if ((IS_OSX && event.metaKey) || (!IS_OSX && event.ctrlKey)) {
+ this._walker.emit(
+ "picker-node-previewed",
+ this._findAndAttachElement(event)
+ );
+ return;
+ }
+
+ this._stopPicking();
+
+ if (!this._currentNode) {
+ this._currentNode = this._findAndAttachElement(event);
+ }
+
+ this._walker.emit("picker-node-picked", this._currentNode);
+ }
+
+ _onHovered(event) {
+ // If the hovered node is a remote frame, then we need to let the event through
+ // since there's a highlighter actor in that sub-frame also picking.
+ if (isRemoteBrowserElement(event.target)) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ // Always call remoteNodePickerNotice handleHoveredElement so the hover state can be updated
+ // (it doesn't have its own event listeners to avoid managing events and suppressed
+ // events for the same target from different places).
+ if (this.#remoteNodePickerNoticeHighlighter) {
+ this.#remoteNodePickerNoticeHighlighter.handleHoveredElement(event);
+ if (this._isEventInRemoteNodePickerNotice(event)) {
+ return;
+ }
+ }
+
+ this._currentNode = this._findAndAttachElement(event);
+ if (this._hoveredNode !== this._currentNode.node) {
+ this._walker.emit("picker-node-hovered", this._currentNode);
+ this._hoveredNode = this._currentNode.node;
+ }
+ }
+
+ _onKey(event) {
+ if (!this._currentNode || !this._isPicking) {
+ return;
+ }
+
+ this._preventContentEvent(event);
+ if (!this._isEventAllowed(event)) {
+ return;
+ }
+
+ let currentNode = this._currentNode.node.rawNode;
+
+ /**
+ * KEY: Action/scope
+ * LEFT_KEY: wider or parent
+ * RIGHT_KEY: narrower or child
+ * ENTER/CARRIAGE_RETURN: Picks currentNode
+ * ESC/CTRL+SHIFT+C: Cancels picker, picks currentNode
+ */
+ switch (event.keyCode) {
+ // Wider.
+ case event.DOM_VK_LEFT:
+ if (!currentNode.parentElement) {
+ return;
+ }
+ currentNode = currentNode.parentElement;
+ break;
+
+ // Narrower.
+ case event.DOM_VK_RIGHT:
+ if (!currentNode.children.length) {
+ return;
+ }
+
+ // Set firstElementChild by default
+ let child = currentNode.firstElementChild;
+ // If currentNode is parent of hoveredNode, then
+ // previously selected childNode is set
+ const hoveredNode = this._hoveredNode.rawNode;
+ for (const sibling of currentNode.children) {
+ if (sibling.contains(hoveredNode) || sibling === hoveredNode) {
+ child = sibling;
+ }
+ }
+
+ currentNode = child;
+ break;
+
+ // Select the element.
+ case event.DOM_VK_RETURN:
+ this._onPick(event);
+ return;
+
+ // Cancel pick mode.
+ case event.DOM_VK_ESCAPE:
+ this.cancelPick();
+ this._walker.emit("picker-node-canceled");
+ return;
+ case event.DOM_VK_C:
+ const { altKey, ctrlKey, metaKey, shiftKey } = event;
+
+ if (
+ (IS_OSX && metaKey && altKey | shiftKey) ||
+ (!IS_OSX && ctrlKey && shiftKey)
+ ) {
+ this.cancelPick();
+ this._walker.emit("picker-node-canceled");
+ }
+ return;
+ default:
+ return;
+ }
+
+ // Store currently attached element
+ this._currentNode = this._walker.attachElement(currentNode);
+ this._walker.emit("picker-node-hovered", this._currentNode);
+ }
+
+ _onSuppressedEvent(event) {
+ if (event.type == "mousemove") {
+ this._onHovered(event);
+ } else if (event.type == "mouseup") {
+ // Suppressed mousedown/mouseup events will be sent to us before they have
+ // been converted into click events. Just treat any mouseup as a click.
+ this._onPick(event);
+ }
+ }
+
+ // In most cases, we need to prevent content events from reaching the content. This is
+ // needed to avoid triggering actions such as submitting forms or following links.
+ // In the case where the event happens on a remote frame however, we do want to let it
+ // through. That is because otherwise the pickers started in nested remote frames will
+ // never have a chance of picking their own elements.
+ _preventContentEvent(event) {
+ if (isRemoteBrowserElement(event.target)) {
+ return;
+ }
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+ /**
+ * When the debugger pauses execution in a page, events will not be delivered
+ * to any handlers added to elements on that page. This method uses the
+ * document's setSuppressedEventListener interface to bypass this restriction:
+ * events will be delivered to the callback at times when they would
+ * otherwise be suppressed. The set of events delivered this way is currently
+ * limited to mouse events.
+ *
+ * @param callback The function to call with suppressed events, or null.
+ */
+ _setSuppressedEventListener(callback) {
+ if (!this._targetActor?.window?.document) {
+ return;
+ }
+
+ // Pass the callback to setSuppressedEventListener as an EventListener.
+ this._targetActor.window.document.setSuppressedEventListener(
+ callback ? { handleEvent: callback } : null
+ );
+ }
+
+ _startPickerListeners() {
+ const target = this._targetActor.chromeEventHandler;
+ this.#eventListenersAbortController = new AbortController();
+ const config = {
+ capture: true,
+ signal: this.#eventListenersAbortController.signal,
+ };
+ target.addEventListener("mousemove", this._onHovered, config);
+ target.addEventListener("click", this._onPick, config);
+ target.addEventListener("mousedown", this._preventContentEvent, config);
+ target.addEventListener("mouseup", this._preventContentEvent, config);
+ target.addEventListener("dblclick", this._preventContentEvent, config);
+ target.addEventListener("keydown", this._onKey, config);
+ target.addEventListener("keyup", this._preventContentEvent, config);
+
+ this._setSuppressedEventListener(this._onSuppressedEvent);
+ }
+
+ _stopPickerListeners() {
+ this._setSuppressedEventListener(null);
+
+ if (this.#eventListenersAbortController) {
+ this.#eventListenersAbortController.abort();
+ this.#eventListenersAbortController = null;
+ }
+ }
+
+ _stopPicking() {
+ this._stopPickerListeners();
+ this._isPicking = false;
+ this._hoveredNode = null;
+ if (this.#remoteNodePickerNoticeHighlighter) {
+ this.#remoteNodePickerNoticeHighlighter.hide();
+ }
+ }
+
+ cancelPick() {
+ if (this._targetActor.threadActor) {
+ this._targetActor.threadActor.showOverlay();
+ }
+
+ if (this._isPicking) {
+ this._stopPicking();
+ }
+ }
+
+ pick(doFocus = false, isLocalTab = true) {
+ if (this._targetActor.threadActor) {
+ this._targetActor.threadActor.hideOverlay();
+ }
+
+ if (this._isPicking) {
+ return;
+ }
+
+ this._startPickerListeners();
+ this._isPicking = true;
+
+ if (doFocus) {
+ this._targetActor.window.focus();
+ }
+
+ if (!isLocalTab) {
+ this.remoteNodePickerNoticeHighlighter.show();
+ }
+ }
+
+ resetHoveredNodeReference() {
+ this._hoveredNode = null;
+ }
+
+ destroy() {
+ this.cancelPick();
+
+ this._targetActor = null;
+ this._walker = null;
+ this.#remoteNodePickerNoticeHighlighter = null;
+ }
+}
+
+exports.NodePicker = NodePicker;
diff --git a/devtools/server/actors/inspector/node.js b/devtools/server/actors/inspector/node.js
new file mode 100644
index 0000000000..294e3e9564
--- /dev/null
+++ b/devtools/server/actors/inspector/node.js
@@ -0,0 +1,861 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ nodeSpec,
+ nodeListSpec,
+} = require("resource://devtools/shared/specs/node.js");
+
+const {
+ PSEUDO_CLASSES,
+} = require("resource://devtools/shared/css/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["getCssPath", "getXPath", "findCssSelector"],
+ "resource://devtools/shared/inspector/css-logic.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "getShadowRootMode",
+ "isAfterPseudoElement",
+ "isAnonymous",
+ "isBeforePseudoElement",
+ "isDirectShadowHostChild",
+ "isFrameBlockedByCSP",
+ "isFrameWithChildTarget",
+ "isMarkerPseudoElement",
+ "isNativeAnonymous",
+ "isShadowHost",
+ "isShadowRoot",
+ ],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "getBackgroundColor",
+ "getClosestBackgroundColor",
+ "getNodeDisplayName",
+ "imageToImageData",
+ "isNodeDead",
+ ],
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "resource://devtools/server/actors/string.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getFontPreviewData",
+ "resource://devtools/server/actors/utils/style-utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EventCollector",
+ "resource://devtools/server/actors/inspector/event-collector.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "DOMHelpers",
+ "resource://devtools/shared/dom-helpers.js",
+ true
+);
+
+const FONT_FAMILY_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog";
+const FONT_FAMILY_PREVIEW_TEXT_SIZE = 20;
+
+/**
+ * Server side of the node actor.
+ */
+class NodeActor extends Actor {
+ constructor(walker, node) {
+ super(walker.conn, nodeSpec);
+ this.walker = walker;
+ this.rawNode = node;
+ this._eventCollector = new EventCollector(this.walker.targetActor);
+ // Map<id -> nsIEventListenerInfo> that we maintain to be able to disable/re-enable event listeners
+ // The id is generated from getEventListenerInfo
+ this._nsIEventListenersInfo = new Map();
+
+ // Store the original display type and scrollable state and whether or not the node is
+ // displayed to track changes when reflows occur.
+ const wasScrollable = this.isScrollable;
+
+ this.currentDisplayType = this.displayType;
+ this.wasDisplayed = this.isDisplayed;
+ this.wasScrollable = wasScrollable;
+ this.currentContainerType = this.containerType;
+
+ if (wasScrollable) {
+ this.walker.updateOverflowCausingElements(
+ this,
+ this.walker.overflowCausingElementsMap
+ );
+ }
+ }
+
+ toString() {
+ return (
+ "[NodeActor " + this.actorID + " for " + this.rawNode.toString() + "]"
+ );
+ }
+
+ isDocumentElement() {
+ return (
+ this.rawNode.ownerDocument &&
+ this.rawNode.ownerDocument.documentElement === this.rawNode
+ );
+ }
+
+ destroy() {
+ super.destroy();
+
+ if (this.mutationObserver) {
+ if (!Cu.isDeadWrapper(this.mutationObserver)) {
+ this.mutationObserver.disconnect();
+ }
+ this.mutationObserver = null;
+ }
+
+ if (this.slotchangeListener) {
+ if (!isNodeDead(this)) {
+ this.rawNode.removeEventListener("slotchange", this.slotchangeListener);
+ }
+ this.slotchangeListener = null;
+ }
+
+ if (this._waitForFrameLoadAbortController) {
+ this._waitForFrameLoadAbortController.abort();
+ this._waitForFrameLoadAbortController = null;
+ }
+ if (this._waitForFrameLoadIntervalId) {
+ clearInterval(this._waitForFrameLoadIntervalId);
+ this._waitForFrameLoadIntervalId = null;
+ }
+
+ if (this._nsIEventListenersInfo) {
+ // Re-enable all event listeners that we might have disabled
+ for (const nsIEventListenerInfo of this._nsIEventListenersInfo.values()) {
+ // If event listeners/node don't exist anymore, accessing nsIEventListenerInfo.enabled
+ // will throw.
+ try {
+ if (!nsIEventListenerInfo.enabled) {
+ nsIEventListenerInfo.enabled = true;
+ }
+ } catch (e) {
+ // ignore
+ }
+ }
+ this._nsIEventListenersInfo = null;
+ }
+
+ this._eventCollector.destroy();
+ this._eventCollector = null;
+ this.rawNode = null;
+ this.walker = null;
+ }
+
+ // Returns the JSON representation of this object over the wire.
+ form() {
+ const parentNode = this.walker.parentNode(this);
+ const inlineTextChild = this.walker.inlineTextChild(this);
+ const shadowRoot = isShadowRoot(this.rawNode);
+ const hostActor = shadowRoot
+ ? this.walker.getNode(this.rawNode.host)
+ : null;
+
+ const form = {
+ actor: this.actorID,
+ host: hostActor ? hostActor.actorID : undefined,
+ baseURI: this.rawNode.baseURI,
+ parent: parentNode ? parentNode.actorID : undefined,
+ nodeType: this.rawNode.nodeType,
+ namespaceURI: this.rawNode.namespaceURI,
+ nodeName: this.rawNode.nodeName,
+ nodeValue: this.rawNode.nodeValue,
+ displayName: getNodeDisplayName(this.rawNode),
+ numChildren: this.numChildren,
+ inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined,
+ displayType: this.displayType,
+ isScrollable: this.isScrollable,
+ isTopLevelDocument: this.isTopLevelDocument,
+ causesOverflow: this.walker.overflowCausingElementsMap.has(this.rawNode),
+ containerType: this.containerType,
+
+ // doctype attributes
+ name: this.rawNode.name,
+ publicId: this.rawNode.publicId,
+ systemId: this.rawNode.systemId,
+
+ attrs: this.writeAttrs(),
+ customElementLocation: this.getCustomElementLocation(),
+ isMarkerPseudoElement: isMarkerPseudoElement(this.rawNode),
+ isBeforePseudoElement: isBeforePseudoElement(this.rawNode),
+ isAfterPseudoElement: isAfterPseudoElement(this.rawNode),
+ isAnonymous: isAnonymous(this.rawNode),
+ isNativeAnonymous: isNativeAnonymous(this.rawNode),
+ isShadowRoot: shadowRoot,
+ shadowRootMode: getShadowRootMode(this.rawNode),
+ isShadowHost: isShadowHost(this.rawNode),
+ isDirectShadowHostChild: isDirectShadowHostChild(this.rawNode),
+ pseudoClassLocks: this.writePseudoClassLocks(),
+ mutationBreakpoints: this.walker.getMutationBreakpoints(this),
+
+ isDisplayed: this.isDisplayed,
+ isInHTMLDocument:
+ this.rawNode.ownerDocument &&
+ this.rawNode.ownerDocument.contentType === "text/html",
+ hasEventListeners: this._hasEventListeners,
+ traits: {},
+ };
+
+ if (this.isDocumentElement()) {
+ form.isDocumentElement = true;
+ }
+
+ if (isFrameBlockedByCSP(this.rawNode)) {
+ form.numChildren = 0;
+ }
+
+ // Flag the node if a different walker is needed to retrieve its children (i.e. if
+ // this is a remote frame, or if it's an iframe and we're creating targets for every iframes)
+ if (this.useChildTargetToFetchChildren) {
+ form.useChildTargetToFetchChildren = true;
+ // Declare at least one child (the #document element) so
+ // that they can be expanded.
+ form.numChildren = 1;
+ }
+ form.browsingContextID = this.rawNode.browsingContext?.id;
+
+ return form;
+ }
+
+ /**
+ * Watch the given document node for mutations using the DOM observer
+ * API.
+ */
+ watchDocument(doc, callback) {
+ if (!doc.defaultView) {
+ return;
+ }
+
+ const node = this.rawNode;
+ // Create the observer on the node's actor. The node will make sure
+ // the observer is cleaned up when the actor is released.
+ const observer = new doc.defaultView.MutationObserver(callback);
+ observer.mergeAttributeRecords = true;
+ observer.observe(node, {
+ attributes: true,
+ characterData: true,
+ characterDataOldValue: true,
+ childList: true,
+ subtree: true,
+ chromeOnlyNodes: true,
+ });
+ this.mutationObserver = observer;
+ }
+
+ /**
+ * Watch for all "slotchange" events on the node.
+ */
+ watchSlotchange(callback) {
+ this.slotchangeListener = callback;
+ this.rawNode.addEventListener("slotchange", this.slotchangeListener);
+ }
+
+ /**
+ * Check if the current node represents an element (e.g. an iframe) which has a dedicated
+ * target for its underlying document that we would need to use to fetch the child nodes.
+ * This will be the case for iframes if EFT is enabled, or if this is a remote iframe and
+ * fission is enabled.
+ */
+ get useChildTargetToFetchChildren() {
+ return isFrameWithChildTarget(this.walker.targetActor, this.rawNode);
+ }
+
+ get isTopLevelDocument() {
+ return this.rawNode === this.walker.rootDoc;
+ }
+
+ // Estimate the number of children that the walker will return without making
+ // a call to children() if possible.
+ get numChildren() {
+ // For pseudo elements, childNodes.length returns 1, but the walker
+ // will return 0.
+ if (
+ isMarkerPseudoElement(this.rawNode) ||
+ isBeforePseudoElement(this.rawNode) ||
+ isAfterPseudoElement(this.rawNode)
+ ) {
+ return 0;
+ }
+
+ const rawNode = this.rawNode;
+ let numChildren = rawNode.childNodes.length;
+ const hasContentDocument = rawNode.contentDocument;
+ const hasSVGDocument = rawNode.getSVGDocument && rawNode.getSVGDocument();
+ if (numChildren === 0 && (hasContentDocument || hasSVGDocument)) {
+ // This might be an iframe with virtual children.
+ numChildren = 1;
+ }
+
+ // Normal counting misses ::before/::after. Also, some anonymous children
+ // may ultimately be skipped, so we have to consult with the walker.
+ //
+ // FIXME: We should be able to just check <slot> rather than
+ // containingShadowRoot.
+ if (
+ numChildren === 0 ||
+ isShadowHost(this.rawNode) ||
+ this.rawNode.containingShadowRoot
+ ) {
+ numChildren = this.walker.countChildren(this);
+ }
+
+ return numChildren;
+ }
+
+ get computedStyle() {
+ if (!this._computedStyle) {
+ this._computedStyle = CssLogic.getComputedStyle(this.rawNode);
+ }
+ return this._computedStyle;
+ }
+
+ /**
+ * Returns the computed display style property value of the node.
+ */
+ get displayType() {
+ // Consider all non-element nodes as displayed.
+ if (isNodeDead(this) || this.rawNode.nodeType !== Node.ELEMENT_NODE) {
+ return null;
+ }
+
+ const style = this.computedStyle;
+ if (!style) {
+ return null;
+ }
+
+ let display = null;
+ try {
+ display = style.display;
+ } catch (e) {
+ // Fails for <scrollbar> elements.
+ }
+
+ if (
+ (display === "grid" || display === "inline-grid") &&
+ (style.gridTemplateRows.startsWith("subgrid") ||
+ style.gridTemplateColumns.startsWith("subgrid"))
+ ) {
+ display = "subgrid";
+ }
+
+ return display;
+ }
+
+ /**
+ * Returns the computed containerType style property value of the node.
+ */
+ get containerType() {
+ // non-element nodes can't be containers
+ if (
+ isNodeDead(this) ||
+ this.rawNode.nodeType !== Node.ELEMENT_NODE ||
+ !this.computedStyle
+ ) {
+ return null;
+ }
+
+ return this.computedStyle.containerType;
+ }
+
+ /**
+ * Check whether the node currently has scrollbars and is scrollable.
+ */
+ get isScrollable() {
+ return (
+ this.rawNode.nodeType === Node.ELEMENT_NODE &&
+ this.rawNode.hasVisibleScrollbars
+ );
+ }
+
+ /**
+ * Is the node currently displayed?
+ */
+ get isDisplayed() {
+ const type = this.displayType;
+
+ // Consider all non-elements or elements with no display-types to be displayed.
+ if (!type) {
+ return true;
+ }
+
+ // Otherwise consider elements to be displayed only if their display-types is other
+ // than "none"".
+ return type !== "none";
+ }
+
+ /**
+ * Are there event listeners that are listening on this node? This method
+ * uses all parsers registered via event-parsers.js.registerEventParser() to
+ * check if there are any event listeners.
+ */
+ get _hasEventListeners() {
+ // We need to pass a debugger instance from this compartment because
+ // otherwise we can't make use of it inside the event-collector module.
+ const dbg = this.getParent().targetActor.makeDebugger();
+ return this._eventCollector.hasEventListeners(this.rawNode, dbg);
+ }
+
+ writeAttrs() {
+ // If the node has no attributes or this.rawNode is the document node and a
+ // node with `name="attributes"` exists in the DOM we need to bail.
+ if (
+ !this.rawNode.attributes ||
+ !NamedNodeMap.isInstance(this.rawNode.attributes)
+ ) {
+ return undefined;
+ }
+
+ return [...this.rawNode.attributes].map(attr => {
+ return { namespace: attr.namespace, name: attr.name, value: attr.value };
+ });
+ }
+
+ writePseudoClassLocks() {
+ if (this.rawNode.nodeType !== Node.ELEMENT_NODE) {
+ return undefined;
+ }
+ let ret = undefined;
+ for (const pseudo of PSEUDO_CLASSES) {
+ if (InspectorUtils.hasPseudoClassLock(this.rawNode, pseudo)) {
+ ret = ret || [];
+ ret.push(pseudo);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Retrieve the script location of the custom element definition for this node, when
+ * relevant. To be linked to a custom element definition
+ */
+ getCustomElementLocation() {
+ // Get a reference to the custom element definition function.
+ const name = this.rawNode.localName;
+
+ if (!this.rawNode.ownerGlobal) {
+ return undefined;
+ }
+
+ const customElementsRegistry = this.rawNode.ownerGlobal.customElements;
+ const customElement =
+ customElementsRegistry && customElementsRegistry.get(name);
+ if (!customElement) {
+ return undefined;
+ }
+ // Create debugger object for the customElement function.
+ const global = Cu.getGlobalForObject(customElement);
+
+ const dbg = this.getParent().targetActor.makeDebugger();
+
+ // If we hit a <browser> element of Firefox, its global will be the chrome window
+ // which is system principal and will be in the same compartment as the debuggee.
+ // For some reason, this happens when we run the content toolbox. As for the content
+ // toolboxes, the modules are loaded in the same compartment as the <browser> element,
+ // this throws as the debugger can _not_ be in the same compartment as the debugger.
+ // This happens when we toggle fission for content toolbox because we try to reparent
+ // the Walker of the tab. This happens because we do not detect in Walker.reparentRemoteFrame
+ // that the target of the tab is the top level. That's because the target is a WindowGlobalTargetActor
+ // which is retrieved via Node.getEmbedderElement and doesn't return the LocalTabTargetActor.
+ // We should probably work on TabDescriptor so that the LocalTabTargetActor has a descriptor,
+ // and see if we can possibly move the local tab specific out of the TargetActor and have
+ // the TabDescriptor expose a pure WindowGlobalTargetActor?? (See bug 1579042)
+ if (Cu.getObjectPrincipal(global) == Cu.getObjectPrincipal(dbg)) {
+ return undefined;
+ }
+
+ const globalDO = dbg.addDebuggee(global);
+ const customElementDO = globalDO.makeDebuggeeValue(customElement);
+
+ // Return undefined if we can't find a script for the custom element definition.
+ if (!customElementDO.script) {
+ return undefined;
+ }
+
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = customElementDO.script.format === "wasm" ? 0 : 1;
+
+ return {
+ url: customElementDO.script.url,
+ line: customElementDO.script.startLine,
+ column: customElementDO.script.startColumn - columnBase,
+ };
+ }
+
+ /**
+ * Returns a LongStringActor with the node's value.
+ */
+ getNodeValue() {
+ return new LongStringActor(this.conn, this.rawNode.nodeValue || "");
+ }
+
+ /**
+ * Set the node's value to a given string.
+ */
+ setNodeValue(value) {
+ this.rawNode.nodeValue = value;
+ }
+
+ /**
+ * Get a unique selector string for this node.
+ */
+ getUniqueSelector() {
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ return "";
+ }
+ return findCssSelector(this.rawNode);
+ }
+
+ /**
+ * Get the full CSS path for this node.
+ *
+ * @return {String} A CSS selector with a part for the node and each of its ancestors.
+ */
+ getCssPath() {
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ return "";
+ }
+ return getCssPath(this.rawNode);
+ }
+
+ /**
+ * Get the XPath for this node.
+ *
+ * @return {String} The XPath for finding this node on the page.
+ */
+ getXPath() {
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ return "";
+ }
+ return getXPath(this.rawNode);
+ }
+
+ /**
+ * Scroll the selected node into view.
+ */
+ scrollIntoView() {
+ this.rawNode.scrollIntoView(true);
+ }
+
+ /**
+ * Get the node's image data if any (for canvas and img nodes).
+ * Returns an imageData object with the actual data being a LongStringActor
+ * and a size json object.
+ * The image data is transmitted as a base64 encoded png data-uri.
+ * The method rejects if the node isn't an image or if the image is missing
+ *
+ * Accepts a maxDim request parameter to resize images that are larger. This
+ * is important as the resizing occurs server-side so that image-data being
+ * transfered in the longstring back to the client will be that much smaller
+ */
+ getImageData(maxDim) {
+ return imageToImageData(this.rawNode, maxDim).then(imageData => {
+ return {
+ data: new LongStringActor(this.conn, imageData.data),
+ size: imageData.size,
+ };
+ });
+ }
+
+ /**
+ * Get all event listeners that are listening on this node.
+ */
+ getEventListenerInfo() {
+ this._nsIEventListenersInfo.clear();
+
+ const eventListenersData = this._eventCollector.getEventListeners(
+ this.rawNode
+ );
+ let counter = 0;
+ for (const eventListenerData of eventListenersData) {
+ if (eventListenerData.nsIEventListenerInfo) {
+ const id = `event-listener-info-${++counter}`;
+ this._nsIEventListenersInfo.set(
+ id,
+ eventListenerData.nsIEventListenerInfo
+ );
+
+ eventListenerData.eventListenerInfoId = id;
+ // remove the nsIEventListenerInfo since we don't want to send it to the client.
+ delete eventListenerData.nsIEventListenerInfo;
+ }
+ }
+ return eventListenersData;
+ }
+
+ /**
+ * Disable a specific event listener given its associated id
+ *
+ * @param {String} eventListenerInfoId
+ */
+ disableEventListener(eventListenerInfoId) {
+ const nsEventListenerInfo =
+ this._nsIEventListenersInfo.get(eventListenerInfoId);
+ if (!nsEventListenerInfo) {
+ throw new Error("Unkown nsEventListenerInfo");
+ }
+ nsEventListenerInfo.enabled = false;
+ }
+
+ /**
+ * (Re-)enable a specific event listener given its associated id
+ *
+ * @param {String} eventListenerInfoId
+ */
+ enableEventListener(eventListenerInfoId) {
+ const nsEventListenerInfo =
+ this._nsIEventListenersInfo.get(eventListenerInfoId);
+ if (!nsEventListenerInfo) {
+ throw new Error("Unkown nsEventListenerInfo");
+ }
+ nsEventListenerInfo.enabled = true;
+ }
+
+ /**
+ * Modify a node's attributes. Passed an array of modifications
+ * similar in format to "attributes" mutations.
+ * {
+ * attributeName: <string>
+ * attributeNamespace: <optional string>
+ * newValue: <optional string> - If null or undefined, the attribute
+ * will be removed.
+ * }
+ *
+ * Returns when the modifications have been made. Mutations will
+ * be queued for any changes made.
+ */
+ modifyAttributes(modifications) {
+ const rawNode = this.rawNode;
+ for (const change of modifications) {
+ if (change.newValue == null) {
+ if (change.attributeNamespace) {
+ rawNode.removeAttributeNS(
+ change.attributeNamespace,
+ change.attributeName
+ );
+ } else {
+ rawNode.removeAttribute(change.attributeName);
+ }
+ } else if (change.attributeNamespace) {
+ rawNode.setAttributeDevtoolsNS(
+ change.attributeNamespace,
+ change.attributeName,
+ change.newValue
+ );
+ } else {
+ rawNode.setAttributeDevtools(change.attributeName, change.newValue);
+ }
+ }
+ }
+
+ /**
+ * Given the font and fill style, get the image data of a canvas with the
+ * preview text and font.
+ * Returns an imageData object with the actual data being a LongStringActor
+ * and the width of the text as a string.
+ * The image data is transmitted as a base64 encoded png data-uri.
+ */
+ getFontFamilyDataURL(font, fillStyle = "black") {
+ const doc = this.rawNode.ownerDocument;
+ const options = {
+ previewText: FONT_FAMILY_PREVIEW_TEXT,
+ previewFontSize: FONT_FAMILY_PREVIEW_TEXT_SIZE,
+ fillStyle,
+ };
+ const { dataURL, size } = getFontPreviewData(font, doc, options);
+
+ return { data: new LongStringActor(this.conn, dataURL), size };
+ }
+
+ /**
+ * Finds the computed background color of the closest parent with a set background
+ * color.
+ *
+ * @return {String}
+ * String with the background color of the form rgba(r, g, b, a). Defaults to
+ * rgba(255, 255, 255, 1) if no background color is found.
+ */
+ getClosestBackgroundColor() {
+ return getClosestBackgroundColor(this.rawNode);
+ }
+
+ /**
+ * Finds the background color range for the parent of a single text node
+ * (i.e. for multi-colored backgrounds with gradients, images) or a single
+ * background color for single-colored backgrounds. Defaults to the closest
+ * background color if an error is encountered.
+ *
+ * @return {Object}
+ * Object with one or more of the following properties: value, min, max
+ */
+ getBackgroundColor() {
+ return getBackgroundColor(this);
+ }
+
+ /**
+ * Returns an object with the width and height of the node's owner window.
+ *
+ * @return {Object}
+ */
+ getOwnerGlobalDimensions() {
+ const win = this.rawNode.ownerGlobal;
+ return {
+ innerWidth: win.innerWidth,
+ innerHeight: win.innerHeight,
+ };
+ }
+
+ /**
+ * If the current node is an iframe, wait for the content window to be loaded.
+ */
+ async waitForFrameLoad() {
+ if (this.useChildTargetToFetchChildren) {
+ // If the document is handled by a dedicated target, we'll wait for a DOCUMENT_EVENT
+ // on the created target.
+ throw new Error(
+ "iframe content document has its own target, use that one instead"
+ );
+ }
+
+ if (Cu.isDeadWrapper(this.rawNode)) {
+ throw new Error("Node is dead");
+ }
+
+ const { contentDocument } = this.rawNode;
+ if (!contentDocument) {
+ throw new Error("Can't access contentDocument");
+ }
+
+ if (contentDocument.readyState === "uninitialized") {
+ // If the readyState is "uninitialized", the document is probably an about:blank
+ // transient document. In such case, we want to wait until the "final" document
+ // is inserted.
+
+ const { chromeEventHandler } = this.rawNode.ownerGlobal.docShell;
+ const browsingContextID = this.rawNode.browsingContext.id;
+ await new Promise((resolve, reject) => {
+ this._waitForFrameLoadAbortController = new AbortController();
+
+ chromeEventHandler.addEventListener(
+ "DOMDocElementInserted",
+ e => {
+ const { browsingContext } = e.target.defaultView;
+ // Check that the document we're notified about is the iframe one.
+ if (browsingContext.id == browsingContextID) {
+ resolve();
+ this._waitForFrameLoadAbortController.abort();
+ }
+ },
+ { signal: this._waitForFrameLoadAbortController.signal }
+ );
+
+ // It might happen that the "final" document will be a remote one, living in a
+ // different process, which means we won't get the DOMDocElementInserted event
+ // here, and will wait forever. To prevent this Promise to hang forever, we use
+ // a setInterval to check if the final document can be reached, so we can reject
+ // if it's not.
+ // This is definitely not a perfect solution, but I wasn't able to find something
+ // better for this feature. I think it's _fine_ as this method will be removed
+ // when EFT is enabled everywhere in release.
+ this._waitForFrameLoadIntervalId = setInterval(() => {
+ if (Cu.isDeadWrapper(this.rawNode) || !this.rawNode.contentDocument) {
+ reject("Can't access the iframe content document");
+ clearInterval(this._waitForFrameLoadIntervalId);
+ this._waitForFrameLoadIntervalId = null;
+ this._waitForFrameLoadAbortController.abort();
+ }
+ }, 50);
+ });
+ }
+
+ if (this.rawNode.contentDocument.readyState === "loading") {
+ await new Promise(resolve => {
+ DOMHelpers.onceDOMReady(this.rawNode.contentWindow, resolve);
+ });
+ }
+ }
+}
+
+/**
+ * Server side of a node list as returned by querySelectorAll()
+ */
+class NodeListActor extends Actor {
+ constructor(walker, nodeList) {
+ super(walker.conn, nodeListSpec);
+ this.walker = walker;
+ this.nodeList = nodeList || [];
+ }
+
+ /**
+ * Items returned by this actor should belong to the parent walker.
+ */
+ marshallPool() {
+ return this.walker;
+ }
+
+ // Returns the JSON representation of this object over the wire.
+ form() {
+ return {
+ actor: this.actorID,
+ length: this.nodeList ? this.nodeList.length : 0,
+ };
+ }
+
+ /**
+ * Get a single node from the node list.
+ */
+ item(index) {
+ return this.walker.attachElement(this.nodeList[index]);
+ }
+
+ /**
+ * Get a range of the items from the node list.
+ */
+ items(start = 0, end = this.nodeList.length) {
+ const items = Array.prototype.slice
+ .call(this.nodeList, start, end)
+ .map(item => this.walker._getOrCreateNodeActor(item));
+ return this.walker.attachElements(items);
+ }
+
+ release() {}
+}
+
+exports.NodeActor = NodeActor;
+exports.NodeListActor = NodeListActor;
diff --git a/devtools/server/actors/inspector/utils.js b/devtools/server/actors/inspector/utils.js
new file mode 100644
index 0000000000..88c1d45605
--- /dev/null
+++ b/devtools/server/actors/inspector/utils.js
@@ -0,0 +1,570 @@
+/* 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
+);
+loader.lazyRequireGetter(
+ this,
+ "AsyncUtils",
+ "resource://devtools/shared/async-utils.js"
+);
+loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js");
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "nodeFilterConstants",
+ "resource://devtools/shared/dom-node-filter-constants.js"
+);
+loader.lazyRequireGetter(
+ this,
+ ["isNativeAnonymous", "getAdjustedQuads"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getBackgroundFor",
+ "resource://devtools/server/actors/accessibility/audit/contrast.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["loadSheetForBackgroundCalculation", "removeSheetForBackgroundCalculation"],
+ "resource://devtools/server/actors/utils/accessibility.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getTextProperties",
+ "resource://devtools/shared/accessibility.js",
+ true
+);
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const IMAGE_FETCHING_TIMEOUT = 500;
+
+/**
+ * Returns the properly cased version of the node's tag name, which can be
+ * used when displaying said name in the UI.
+ *
+ * @param {Node} rawNode
+ * Node for which we want the display name
+ * @return {String}
+ * Properly cased version of the node tag name
+ */
+const getNodeDisplayName = function (rawNode) {
+ if (rawNode.nodeName && !rawNode.localName) {
+ // The localName & prefix APIs have been moved from the Node interface to the Element
+ // interface. Use Node.nodeName as a fallback.
+ return rawNode.nodeName;
+ }
+ return (rawNode.prefix ? rawNode.prefix + ":" : "") + rawNode.localName;
+};
+
+/**
+ * Returns flex and grid information about a DOM node.
+ * In particular is it a grid flex/container and/or item?
+ *
+ * @param {DOMNode} node
+ * The node for which then information is required
+ * @return {Object}
+ * An object like { grid: { isContainer, isItem }, flex: { isContainer, isItem } }
+ */
+function getNodeGridFlexType(node) {
+ return {
+ grid: getNodeGridType(node),
+ flex: getNodeFlexType(node),
+ };
+}
+
+function getNodeFlexType(node) {
+ return {
+ isContainer: node.getAsFlexContainer && !!node.getAsFlexContainer(),
+ isItem: !!node.parentFlexElement,
+ };
+}
+
+function getNodeGridType(node) {
+ return {
+ isContainer: node.hasGridFragments && node.hasGridFragments(),
+ isItem: !!findGridParentContainerForNode(node),
+ };
+}
+
+function nodeDocument(node) {
+ if (Cu.isDeadWrapper(node)) {
+ return null;
+ }
+ return (
+ node.ownerDocument || (node.nodeType == Node.DOCUMENT_NODE ? node : null)
+ );
+}
+
+function isNodeDead(node) {
+ return !node || !node.rawNode || Cu.isDeadWrapper(node.rawNode);
+}
+
+function isInXULDocument(el) {
+ const doc = nodeDocument(el);
+ return doc?.documentElement && doc.documentElement.namespaceURI === XUL_NS;
+}
+
+/**
+ * This DeepTreeWalker filter skips whitespace text nodes and anonymous
+ * content with the exception of ::marker, ::before, and ::after, plus anonymous
+ * content in XUL document (needed to show all elements in the browser toolbox).
+ */
+function standardTreeWalkerFilter(node) {
+ // ::marker, ::before, and ::after are native anonymous content, but we always
+ // want to show them
+ if (
+ node.nodeName === "_moz_generated_content_marker" ||
+ node.nodeName === "_moz_generated_content_before" ||
+ node.nodeName === "_moz_generated_content_after"
+ ) {
+ return nodeFilterConstants.FILTER_ACCEPT;
+ }
+
+ // Ignore empty whitespace text nodes that do not impact the layout.
+ if (isWhitespaceTextNode(node)) {
+ return nodeHasSize(node)
+ ? nodeFilterConstants.FILTER_ACCEPT
+ : nodeFilterConstants.FILTER_SKIP;
+ }
+
+ // Ignore all native anonymous roots inside a non-XUL document.
+ // We need to do this to skip things like form controls, scrollbars,
+ // video controls, etc (see bug 1187482).
+ if (isNativeAnonymous(node) && !isInXULDocument(node)) {
+ return nodeFilterConstants.FILTER_SKIP;
+ }
+
+ return nodeFilterConstants.FILTER_ACCEPT;
+}
+
+/**
+ * This DeepTreeWalker filter ignores anonymous content.
+ */
+function noAnonymousContentTreeWalkerFilter(node) {
+ // Ignore all native anonymous content inside a non-XUL document.
+ // We need to do this to skip things like form controls, scrollbars,
+ // video controls, etc (see bug 1187482).
+ if (!isInXULDocument(node) && isNativeAnonymous(node)) {
+ return nodeFilterConstants.FILTER_SKIP;
+ }
+
+ return nodeFilterConstants.FILTER_ACCEPT;
+}
+/**
+ * This DeepTreeWalker filter is like standardTreeWalkerFilter except that
+ * it also includes all anonymous content (like internal form controls).
+ */
+function allAnonymousContentTreeWalkerFilter(node) {
+ // Ignore empty whitespace text nodes that do not impact the layout.
+ if (isWhitespaceTextNode(node)) {
+ return nodeHasSize(node)
+ ? nodeFilterConstants.FILTER_ACCEPT
+ : nodeFilterConstants.FILTER_SKIP;
+ }
+ return nodeFilterConstants.FILTER_ACCEPT;
+}
+
+/**
+ * Is the given node a text node composed of whitespace only?
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+function isWhitespaceTextNode(node) {
+ return node.nodeType == Node.TEXT_NODE && !/[^\s]/.exec(node.nodeValue);
+}
+
+/**
+ * Does the given node have non-0 width and height?
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+function nodeHasSize(node) {
+ if (!node.getBoxQuads) {
+ return false;
+ }
+
+ const quads = node.getBoxQuads({
+ createFramesForSuppressedWhitespace: false,
+ });
+ return quads.some(quad => {
+ const bounds = quad.getBounds();
+ return bounds.width && bounds.height;
+ });
+}
+
+/**
+ * Returns a promise that is settled once the given HTMLImageElement has
+ * finished loading.
+ *
+ * @param {HTMLImageElement} image - The image element.
+ * @param {Number} timeout - Maximum amount of time the image is allowed to load
+ * before the waiting is aborted. Ignored if flags.testing is set.
+ *
+ * @return {Promise} that is fulfilled once the image has loaded. If the image
+ * fails to load or the load takes too long, the promise is rejected.
+ */
+function ensureImageLoaded(image, timeout) {
+ const { HTMLImageElement } = image.ownerGlobal;
+ if (!(image instanceof HTMLImageElement)) {
+ return Promise.reject("image must be an HTMLImageELement");
+ }
+
+ if (image.complete) {
+ // The image has already finished loading.
+ return Promise.resolve();
+ }
+
+ // This image is still loading.
+ const onLoad = AsyncUtils.listenOnce(image, "load");
+
+ // Reject if loading fails.
+ const onError = AsyncUtils.listenOnce(image, "error").then(() => {
+ return Promise.reject("Image '" + image.src + "' failed to load.");
+ });
+
+ // Don't timeout when testing. This is never settled.
+ let onAbort = new Promise(() => {});
+
+ if (!flags.testing) {
+ // Tests are not running. Reject the promise after given timeout.
+ onAbort = DevToolsUtils.waitForTime(timeout).then(() => {
+ return Promise.reject("Image '" + image.src + "' took too long to load.");
+ });
+ }
+
+ // See which happens first.
+ return Promise.race([onLoad, onError, onAbort]);
+}
+
+/**
+ * Given an <img> or <canvas> element, return the image data-uri. If @param node
+ * is an <img> element, the method waits a while for the image to load before
+ * the data is generated. If the image does not finish loading in a reasonable
+ * time (IMAGE_FETCHING_TIMEOUT milliseconds) the process aborts.
+ *
+ * @param {HTMLImageElement|HTMLCanvasElement} node - The <img> or <canvas>
+ * element, or Image() object. Other types cause the method to reject.
+ * @param {Number} maxDim - Optionally pass a maximum size you want the longest
+ * side of the image to be resized to before getting the image data.
+
+ * @return {Promise} A promise that is fulfilled with an object containing the
+ * data-uri and size-related information:
+ * { data: "...",
+ * size: {
+ * naturalWidth: 400,
+ * naturalHeight: 300,
+ * resized: true }
+ * }.
+ *
+ * If something goes wrong, the promise is rejected.
+ */
+const imageToImageData = async function (node, maxDim) {
+ const { HTMLCanvasElement, HTMLImageElement } = node.ownerGlobal;
+
+ const isImg = node instanceof HTMLImageElement;
+ const isCanvas = node instanceof HTMLCanvasElement;
+
+ if (!isImg && !isCanvas) {
+ throw new Error("node is not a <canvas> or <img> element.");
+ }
+
+ if (isImg) {
+ // Ensure that the image is ready.
+ await ensureImageLoaded(node, IMAGE_FETCHING_TIMEOUT);
+ }
+
+ // Get the image resize ratio if a maxDim was provided
+ let resizeRatio = 1;
+ const imgWidth = node.naturalWidth || node.width;
+ const imgHeight = node.naturalHeight || node.height;
+ const imgMax = Math.max(imgWidth, imgHeight);
+ if (maxDim && imgMax > maxDim) {
+ resizeRatio = maxDim / imgMax;
+ }
+
+ // Extract the image data
+ let imageData;
+ // The image may already be a data-uri, in which case, save ourselves the
+ // trouble of converting via the canvas.drawImage.toDataURL method, but only
+ // if the image doesn't need resizing
+ if (isImg && node.src.startsWith("data:") && resizeRatio === 1) {
+ imageData = node.src;
+ } else {
+ // Create a canvas to copy the rawNode into and get the imageData from
+ const canvas = node.ownerDocument.createElementNS(XHTML_NS, "canvas");
+ canvas.width = imgWidth * resizeRatio;
+ canvas.height = imgHeight * resizeRatio;
+ const ctx = canvas.getContext("2d");
+
+ // Copy the rawNode image or canvas in the new canvas and extract data
+ ctx.drawImage(node, 0, 0, canvas.width, canvas.height);
+ imageData = canvas.toDataURL("image/png");
+ }
+
+ return {
+ data: imageData,
+ size: {
+ naturalWidth: imgWidth,
+ naturalHeight: imgHeight,
+ resized: resizeRatio !== 1,
+ },
+ };
+};
+
+/**
+ * Finds the computed background color of the closest parent with a set background color.
+ *
+ * @param {DOMNode} node
+ * Node for which we want to find closest background color.
+ * @return {String}
+ * String with the background color of the form rgba(r, g, b, a). Defaults to
+ * rgba(255, 255, 255, 1) if no background color is found.
+ */
+function getClosestBackgroundColor(node) {
+ let current = node;
+
+ while (current) {
+ const computedStyle = CssLogic.getComputedStyle(current);
+ if (computedStyle) {
+ const currentStyle = computedStyle.getPropertyValue("background-color");
+ if (InspectorUtils.isValidCSSColor(currentStyle)) {
+ const currentCssColor = new colorUtils.CssColor(currentStyle);
+ if (!currentCssColor.isTransparent()) {
+ return currentCssColor.rgba;
+ }
+ }
+ }
+
+ current = current.parentNode;
+ }
+
+ return "rgba(255, 255, 255, 1)";
+}
+
+/**
+ * Finds the background image of the closest parent where it is set.
+ *
+ * @param {DOMNode} node
+ * Node for which we want to find the background image.
+ * @return {String}
+ * String with the value of the background iamge property. Defaults to "none" if
+ * no background image is found.
+ */
+function getClosestBackgroundImage(node) {
+ let current = node;
+
+ while (current) {
+ const computedStyle = CssLogic.getComputedStyle(current);
+ if (computedStyle) {
+ const currentBackgroundImage =
+ computedStyle.getPropertyValue("background-image");
+ if (currentBackgroundImage !== "none") {
+ return currentBackgroundImage;
+ }
+ }
+
+ current = current.parentNode;
+ }
+
+ return "none";
+}
+
+/**
+ * If the provided node is a grid item, then return its parent grid.
+ *
+ * @param {DOMNode} node
+ * The node that is supposedly a grid item.
+ * @return {DOMNode|null}
+ * The parent grid if found, null otherwise.
+ */
+function findGridParentContainerForNode(node) {
+ try {
+ while ((node = node.parentNode)) {
+ const display = node.ownerGlobal.getComputedStyle(node).display;
+
+ if (display.includes("grid")) {
+ return node;
+ } else if (display === "contents") {
+ // Continue walking up the tree since the parent node is a content element.
+ continue;
+ }
+
+ break;
+ }
+ } catch (e) {
+ // Getting the parentNode can fail when the supplied node is in shadow DOM.
+ }
+
+ return null;
+}
+
+/**
+ * Finds the background color range for the parent of a single text node
+ * (i.e. for multi-colored backgrounds with gradients, images) or a single
+ * background color for single-colored backgrounds. Defaults to the closest
+ * background color if an error is encountered.
+ *
+ * @param {Object}
+ * Node actor containing the following properties:
+ * {DOMNode} rawNode
+ * Node for which we want to calculate the color contrast.
+ * {WalkerActor} walker
+ * Walker actor used to check whether the node is the parent elm of a single text node.
+ * @return {Object}
+ * Object with one or more of the following properties:
+ * {Array|null} value
+ * RGBA array for single-colored background. Null for multi-colored backgrounds.
+ * {Array|null} min
+ * RGBA array for the min luminance color in a multi-colored background.
+ * Null for single-colored backgrounds.
+ * {Array|null} max
+ * RGBA array for the max luminance color in a multi-colored background.
+ * Null for single-colored backgrounds.
+ */
+async function getBackgroundColor({ rawNode: node, walker }) {
+ // Fall back to calculating contrast against closest bg if:
+ // - not element node
+ // - more than one child
+ // Avoid calculating bounds and creating doc walker by returning early.
+ if (
+ node.nodeType != Node.ELEMENT_NODE ||
+ node.childNodes.length > 1 ||
+ !node.firstChild
+ ) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ const quads = getAdjustedQuads(node.ownerGlobal, node.firstChild, "content");
+
+ // Fall back to calculating contrast against closest bg if there are no bounds for text node.
+ // Avoid creating doc walker by returning early.
+ if (quads.length === 0 || !quads[0].bounds) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ const bounds = quads[0].bounds;
+
+ const docWalker = walker.getDocumentWalker(node);
+ const firstChild = docWalker.firstChild();
+
+ // Fall back to calculating contrast against closest bg if:
+ // - more than one child
+ // - unique child is not a text node
+ if (
+ !firstChild ||
+ docWalker.nextSibling() ||
+ firstChild.nodeType !== Node.TEXT_NODE
+ ) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ // Try calculating complex backgrounds for node
+ const win = node.ownerGlobal;
+ loadSheetForBackgroundCalculation(win);
+ const computedStyle = CssLogic.getComputedStyle(node);
+ const props = computedStyle ? getTextProperties(computedStyle) : null;
+
+ // Fall back to calculating contrast against closest bg if there are no text props.
+ if (!props) {
+ return {
+ value: getClosestBackgroundColorInRGBA(node),
+ };
+ }
+
+ const bgColor = await getBackgroundFor(node, {
+ bounds,
+ win,
+ convertBoundsRelativeToViewport: false,
+ size: props.size,
+ isBoldText: props.isBoldText,
+ });
+ removeSheetForBackgroundCalculation(win);
+
+ return (
+ bgColor || {
+ value: getClosestBackgroundColorInRGBA(node),
+ }
+ );
+}
+
+/**
+ *
+ * @param {DOMNode} node: The node we want the background color of
+ * @returns {Array[r,g,b,a]}
+ */
+function getClosestBackgroundColorInRGBA(node) {
+ const { r, g, b, a } = InspectorUtils.colorToRGBA(
+ getClosestBackgroundColor(node)
+ );
+ return [r, g, b, a];
+}
+/**
+ * Indicates if a document is ready (i.e. if it's not loading anymore)
+ *
+ * @param {HTMLDocument} document: The document we want to check
+ * @returns {Boolean}
+ */
+function isDocumentReady(document) {
+ if (!document) {
+ return false;
+ }
+
+ const { readyState } = document;
+ if (readyState == "interactive" || readyState == "complete") {
+ return true;
+ }
+
+ // A document might stay forever in unitialized state.
+ // If the target actor is not currently loading a document,
+ // assume the document is ready.
+ const webProgress = document.defaultView.docShell.QueryInterface(
+ Ci.nsIWebProgress
+ );
+ return !webProgress.isLoadingDocument;
+}
+
+module.exports = {
+ allAnonymousContentTreeWalkerFilter,
+ isDocumentReady,
+ isWhitespaceTextNode,
+ findGridParentContainerForNode,
+ getBackgroundColor,
+ getClosestBackgroundColor,
+ getClosestBackgroundImage,
+ getNodeDisplayName,
+ getNodeGridFlexType,
+ imageToImageData,
+ isNodeDead,
+ nodeDocument,
+ standardTreeWalkerFilter,
+ noAnonymousContentTreeWalkerFilter,
+};
diff --git a/devtools/server/actors/inspector/walker.js b/devtools/server/actors/inspector/walker.js
new file mode 100644
index 0000000000..f8da1385e9
--- /dev/null
+++ b/devtools/server/actors/inspector/walker.js
@@ -0,0 +1,2764 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { walkerSpec } = require("resource://devtools/shared/specs/walker.js");
+
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+const {
+ EXCLUDED_LISTENER,
+} = require("resource://devtools/server/actors/inspector/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "nodeFilterConstants",
+ "resource://devtools/shared/dom-node-filter-constants.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "getFrameElement",
+ "isAfterPseudoElement",
+ "isBeforePseudoElement",
+ "isDirectShadowHostChild",
+ "isMarkerPseudoElement",
+ "isFrameBlockedByCSP",
+ "isFrameWithChildTarget",
+ "isShadowHost",
+ "isShadowRoot",
+ "loadSheet",
+ ],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "throttle",
+ "resource://devtools/shared/throttle.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ [
+ "allAnonymousContentTreeWalkerFilter",
+ "findGridParentContainerForNode",
+ "isNodeDead",
+ "noAnonymousContentTreeWalkerFilter",
+ "nodeDocument",
+ "standardTreeWalkerFilter",
+ ],
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "CustomElementWatcher",
+ "resource://devtools/server/actors/inspector/custom-element-watcher.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["DocumentWalker", "SKIP_TO_SIBLING"],
+ "resource://devtools/server/actors/inspector/document-walker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["NodeActor", "NodeListActor"],
+ "resource://devtools/server/actors/inspector/node.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "NodePicker",
+ "resource://devtools/server/actors/inspector/node-picker.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LayoutActor",
+ "resource://devtools/server/actors/layout.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getLayoutChangesObserver", "releaseLayoutChangesObserver"],
+ "resource://devtools/server/actors/reflow.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WalkerSearch",
+ "resource://devtools/server/actors/utils/walker-search.js",
+ true
+);
+
+// ContentDOMReference requires ChromeUtils, which isn't available in worker context.
+const lazy = {};
+if (!isWorker) {
+ loader.lazyGetter(
+ lazy,
+ "ContentDOMReference",
+ () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ContentDOMReference.sys.mjs",
+ {
+ // ContentDOMReference needs to be retrieved from the shared global
+ // since it is a shared singleton.
+ loadInDevToolsLoader: false,
+ }
+ ).ContentDOMReference
+ );
+}
+
+loader.lazyServiceGetter(
+ this,
+ "eventListenerService",
+ "@mozilla.org/eventlistenerservice;1",
+ "nsIEventListenerService"
+);
+
+// Minimum delay between two "new-mutations" events.
+const MUTATIONS_THROTTLING_DELAY = 100;
+// List of mutation types that should -not- be throttled.
+const IMMEDIATE_MUTATIONS = ["pseudoClassLock"];
+
+const HIDDEN_CLASS = "__fx-devtools-hide-shortcut__";
+
+// The possible completions to a ':' with added score to give certain values
+// some preference.
+const PSEUDO_SELECTORS = [
+ [":active", 1],
+ [":hover", 1],
+ [":focus", 1],
+ [":visited", 0],
+ [":link", 0],
+ [":first-letter", 0],
+ [":first-child", 2],
+ [":before", 2],
+ [":after", 2],
+ [":lang(", 0],
+ [":not(", 3],
+ [":first-of-type", 0],
+ [":last-of-type", 0],
+ [":only-of-type", 0],
+ [":only-child", 2],
+ [":nth-child(", 3],
+ [":nth-last-child(", 0],
+ [":nth-of-type(", 0],
+ [":nth-last-of-type(", 0],
+ [":last-child", 2],
+ [":root", 0],
+ [":empty", 0],
+ [":target", 0],
+ [":enabled", 0],
+ [":disabled", 0],
+ [":checked", 1],
+ ["::selection", 0],
+ ["::marker", 0],
+];
+
+const HELPER_SHEET =
+ "data:text/css;charset=utf-8," +
+ encodeURIComponent(`
+ .__fx-devtools-hide-shortcut__ {
+ visibility: hidden !important;
+ }
+`);
+
+/**
+ * We only send nodeValue up to a certain size by default. This stuff
+ * controls that size.
+ */
+exports.DEFAULT_VALUE_SUMMARY_LENGTH = 50;
+var gValueSummaryLength = exports.DEFAULT_VALUE_SUMMARY_LENGTH;
+
+exports.getValueSummaryLength = function () {
+ return gValueSummaryLength;
+};
+
+exports.setValueSummaryLength = function (val) {
+ gValueSummaryLength = val;
+};
+
+/**
+ * Server side of the DOM walker.
+ */
+class WalkerActor extends Actor {
+ /**
+ * Create the WalkerActor
+ * @param {DevToolsServerConnection} conn
+ * The server connection.
+ * @param {TargetActor} targetActor
+ * The top-level Actor for this tab.
+ * @param {Object} options
+ * - {Boolean} showAllAnonymousContent: Show all native anonymous content
+ */
+ constructor(conn, targetActor, options) {
+ super(conn, walkerSpec);
+ this.targetActor = targetActor;
+ this.rootWin = targetActor.window;
+ this.rootDoc = this.rootWin.document;
+
+ // Map of already created node actors, keyed by their corresponding DOMNode.
+ this._nodeActorsMap = new Map();
+
+ this._pendingMutations = [];
+ this._activePseudoClassLocks = new Set();
+ this._mutationBreakpoints = new WeakMap();
+ this._anonParents = new WeakMap();
+ this.customElementWatcher = new CustomElementWatcher(
+ targetActor.chromeEventHandler
+ );
+
+ // In this map, the key-value pairs are the overflow causing elements and their
+ // respective ancestor scrollable node actor.
+ this.overflowCausingElementsMap = new Map();
+
+ this.showAllAnonymousContent = options.showAllAnonymousContent;
+
+ this.walkerSearch = new WalkerSearch(this);
+
+ // Nodes which have been removed from the client's known
+ // ownership tree are considered "orphaned", and stored in
+ // this set.
+ this._orphaned = new Set();
+
+ // The client can tell the walker that it is interested in a node
+ // even when it is orphaned with the `retainNode` method. This
+ // list contains orphaned nodes that were so retained.
+ this._retainedOrphans = new Set();
+
+ this.onSubtreeModified = this.onSubtreeModified.bind(this);
+ this.onSubtreeModified[EXCLUDED_LISTENER] = true;
+ this.onNodeRemoved = this.onNodeRemoved.bind(this);
+ this.onNodeRemoved[EXCLUDED_LISTENER] = true;
+ this.onAttributeModified = this.onAttributeModified.bind(this);
+ this.onAttributeModified[EXCLUDED_LISTENER] = true;
+
+ this.onMutations = this.onMutations.bind(this);
+ this.onSlotchange = this.onSlotchange.bind(this);
+ this.onShadowrootattached = this.onShadowrootattached.bind(this);
+ this.onAnonymousrootcreated = this.onAnonymousrootcreated.bind(this);
+ this.onAnonymousrootremoved = this.onAnonymousrootremoved.bind(this);
+ this.onFrameLoad = this.onFrameLoad.bind(this);
+ this.onFrameUnload = this.onFrameUnload.bind(this);
+ this.onCustomElementDefined = this.onCustomElementDefined.bind(this);
+ this._throttledEmitNewMutations = throttle(
+ this._emitNewMutations.bind(this),
+ MUTATIONS_THROTTLING_DELAY
+ );
+
+ targetActor.on("will-navigate", this.onFrameUnload);
+ targetActor.on("window-ready", this.onFrameLoad);
+
+ this.customElementWatcher.on(
+ "element-defined",
+ this.onCustomElementDefined
+ );
+
+ // Keep a reference to the chromeEventHandler for the current targetActor, to make
+ // sure we will be able to remove the listener during the WalkerActor destroy().
+ this.chromeEventHandler = targetActor.chromeEventHandler;
+ // shadowrootattached is a chrome-only event. We enable it below.
+ this.chromeEventHandler.addEventListener(
+ "shadowrootattached",
+ this.onShadowrootattached
+ );
+ // anonymousrootcreated is a chrome-only event. We enable it below.
+ this.chromeEventHandler.addEventListener(
+ "anonymousrootcreated",
+ this.onAnonymousrootcreated
+ );
+ this.chromeEventHandler.addEventListener(
+ "anonymousrootremoved",
+ this.onAnonymousrootremoved
+ );
+ for (const { document } of this.targetActor.windows) {
+ document.devToolsAnonymousAndShadowEventsEnabled = true;
+ }
+
+ // Ensure that the root document node actor is ready and
+ // managed.
+ this.rootNode = this.document();
+
+ this.layoutChangeObserver = getLayoutChangesObserver(this.targetActor);
+ this._onReflows = this._onReflows.bind(this);
+ this.layoutChangeObserver.on("reflows", this._onReflows);
+ this._onResize = this._onResize.bind(this);
+ this.layoutChangeObserver.on("resize", this._onResize);
+
+ this._onEventListenerChange = this._onEventListenerChange.bind(this);
+ eventListenerService.addListenerChangeListener(this._onEventListenerChange);
+ }
+
+ get nodePicker() {
+ if (!this._nodePicker) {
+ this._nodePicker = new NodePicker(this, this.targetActor);
+ }
+
+ return this._nodePicker;
+ }
+
+ watchRootNode() {
+ if (this.rootNode) {
+ this.emit("root-available", this.rootNode);
+ }
+ }
+
+ /**
+ * Callback for eventListenerService.addListenerChangeListener
+ * @param nsISimpleEnumerator changesEnum
+ * enumerator of nsIEventListenerChange
+ */
+ _onEventListenerChange(changesEnum) {
+ for (const current of changesEnum.enumerate(Ci.nsIEventListenerChange)) {
+ const target = current.target;
+
+ if (this._nodeActorsMap.has(target)) {
+ const actor = this.getNode(target);
+ const mutation = {
+ type: "events",
+ target: actor.actorID,
+ hasEventListeners: actor._hasEventListeners,
+ };
+ this.queueMutation(mutation);
+ }
+ }
+ }
+
+ // Returns the JSON representation of this object over the wire.
+ form() {
+ return {
+ actor: this.actorID,
+ root: this.rootNode.form(),
+ traits: {},
+ };
+ }
+
+ toString() {
+ return "[WalkerActor " + this.actorID + "]";
+ }
+
+ getDocumentWalker(node, skipTo) {
+ // Allow native anon content (like <video> controls) if preffed on
+ const filter = this.showAllAnonymousContent
+ ? allAnonymousContentTreeWalkerFilter
+ : standardTreeWalkerFilter;
+
+ return new DocumentWalker(node, this.rootWin, {
+ filter,
+ skipTo,
+ showAnonymousContent: true,
+ });
+ }
+
+ destroy() {
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+ super.destroy();
+ try {
+ this.clearPseudoClassLocks();
+ this._activePseudoClassLocks = null;
+
+ this.overflowCausingElementsMap.clear();
+ this.overflowCausingElementsMap = null;
+
+ this._hoveredNode = null;
+ this.rootWin = null;
+ this.rootDoc = null;
+ this.rootNode = null;
+ this.layoutHelpers = null;
+ this._orphaned = null;
+ this._retainedOrphans = null;
+ this._nodeActorsMap = null;
+
+ this.targetActor.off("will-navigate", this.onFrameUnload);
+ this.targetActor.off("window-ready", this.onFrameLoad);
+ this.customElementWatcher.off(
+ "element-defined",
+ this.onCustomElementDefined
+ );
+
+ this.chromeEventHandler.removeEventListener(
+ "shadowrootattached",
+ this.onShadowrootattached
+ );
+ this.chromeEventHandler.removeEventListener(
+ "anonymousrootcreated",
+ this.onAnonymousrootcreated
+ );
+ this.chromeEventHandler.removeEventListener(
+ "anonymousrootremoved",
+ this.onAnonymousrootremoved
+ );
+
+ // This attribute is just for devtools, so we can unset once we're done.
+ for (const { document } of this.targetActor.windows) {
+ document.devToolsAnonymousAndShadowEventsEnabled = false;
+ }
+
+ this.onFrameLoad = null;
+ this.onFrameUnload = null;
+
+ this.customElementWatcher.destroy();
+ this.customElementWatcher = null;
+
+ this.walkerSearch.destroy();
+
+ if (this._nodePicker) {
+ this._nodePicker.destroy();
+ this._nodePicker = null;
+ }
+
+ this.layoutChangeObserver.off("reflows", this._onReflows);
+ this.layoutChangeObserver.off("resize", this._onResize);
+ this.layoutChangeObserver = null;
+ releaseLayoutChangesObserver(this.targetActor);
+
+ eventListenerService.removeListenerChangeListener(
+ this._onEventListenerChange
+ );
+
+ this.onMutations = null;
+
+ this.layoutActor = null;
+ this.targetActor = null;
+ this.chromeEventHandler = null;
+
+ this.emit("destroyed");
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ release() {}
+
+ unmanage(actor) {
+ if (actor instanceof NodeActor) {
+ if (
+ this._activePseudoClassLocks &&
+ this._activePseudoClassLocks.has(actor)
+ ) {
+ this.clearPseudoClassLocks(actor);
+ }
+
+ this.customElementWatcher.unmanageNode(actor);
+
+ this._nodeActorsMap.delete(actor.rawNode);
+ }
+ super.unmanage(actor);
+ }
+
+ /**
+ * Determine if the walker has come across this DOM node before.
+ * @param {DOMNode} rawNode
+ * @return {Boolean}
+ */
+ hasNode(rawNode) {
+ return this._nodeActorsMap.has(rawNode);
+ }
+
+ /**
+ * If the walker has come across this DOM node before, then get the
+ * corresponding node actor.
+ * @param {DOMNode} rawNode
+ * @return {NodeActor}
+ */
+ getNode(rawNode) {
+ return this._nodeActorsMap.get(rawNode);
+ }
+
+ /**
+ * Internal helper that will either retrieve the existing NodeActor for the
+ * provided node or create the actor on the fly if it doesn't exist.
+ * This method should only be called when we are sure that the node should be
+ * known by the client and that the parent node is already known.
+ *
+ * Otherwise prefer `getNode` to only retrieve known actors or `attachElement`
+ * to create node actors recursively.
+ *
+ * @param {DOMNode} node
+ * The node for which we want to create or get an actor
+ * @return {NodeActor} The corresponding NodeActor
+ */
+ _getOrCreateNodeActor(node) {
+ let actor = this.getNode(node);
+ if (actor) {
+ return actor;
+ }
+
+ actor = new NodeActor(this, node);
+
+ // Add the node actor as a child of this walker actor, assigning
+ // it an actorID.
+ this.manage(actor);
+ this._nodeActorsMap.set(node, actor);
+
+ if (node.nodeType === Node.DOCUMENT_NODE) {
+ actor.watchDocument(node, this.onMutations);
+ }
+
+ if (isShadowRoot(actor.rawNode)) {
+ actor.watchDocument(node.ownerDocument, this.onMutations);
+ actor.watchSlotchange(this.onSlotchange);
+ }
+
+ this.customElementWatcher.manageNode(actor);
+
+ return actor;
+ }
+
+ /**
+ * When a custom element is defined, send a customElementDefined mutation for all the
+ * NodeActors using this tag name.
+ */
+ onCustomElementDefined({ name, actors }) {
+ actors.forEach(actor =>
+ this.queueMutation({
+ target: actor.actorID,
+ type: "customElementDefined",
+ customElementLocation: actor.getCustomElementLocation(),
+ })
+ );
+ }
+
+ _onReflows(reflows) {
+ // Going through the nodes the walker knows about, see which ones have had their
+ // containerType, display, scrollable or overflow state changed and send events if any.
+ const containerTypeChanges = [];
+ const displayTypeChanges = [];
+ const scrollableStateChanges = [];
+
+ const currentOverflowCausingElementsMap = new Map();
+
+ for (const [node, actor] of this._nodeActorsMap) {
+ if (Cu.isDeadWrapper(node)) {
+ continue;
+ }
+
+ const displayType = actor.displayType;
+ const isDisplayed = actor.isDisplayed;
+
+ if (
+ displayType !== actor.currentDisplayType ||
+ isDisplayed !== actor.wasDisplayed
+ ) {
+ displayTypeChanges.push(actor);
+
+ // Updating the original value
+ actor.currentDisplayType = displayType;
+ actor.wasDisplayed = isDisplayed;
+ }
+
+ const isScrollable = actor.isScrollable;
+ if (isScrollable !== actor.wasScrollable) {
+ scrollableStateChanges.push(actor);
+ actor.wasScrollable = isScrollable;
+ }
+
+ if (isScrollable) {
+ this.updateOverflowCausingElements(
+ actor,
+ currentOverflowCausingElementsMap
+ );
+ }
+
+ const containerType = actor.containerType;
+ if (containerType !== actor.currentContainerType) {
+ containerTypeChanges.push(actor);
+ actor.currentContainerType = containerType;
+ }
+ }
+
+ // Get the NodeActor for each node in the symmetric difference of
+ // currentOverflowCausingElementsMap and this.overflowCausingElementsMap
+ const overflowStateChanges = [...currentOverflowCausingElementsMap.keys()]
+ .filter(node => !this.overflowCausingElementsMap.has(node))
+ .concat(
+ [...this.overflowCausingElementsMap.keys()].filter(
+ node => !currentOverflowCausingElementsMap.has(node)
+ )
+ )
+ .filter(node => this.hasNode(node))
+ .map(node => this.getNode(node));
+
+ this.overflowCausingElementsMap = currentOverflowCausingElementsMap;
+
+ if (overflowStateChanges.length) {
+ this.emit("overflow-change", overflowStateChanges);
+ }
+
+ if (displayTypeChanges.length) {
+ this.emit("display-change", displayTypeChanges);
+ }
+
+ if (scrollableStateChanges.length) {
+ this.emit("scrollable-change", scrollableStateChanges);
+ }
+
+ if (containerTypeChanges.length) {
+ this.emit("container-type-change", containerTypeChanges);
+ }
+ }
+
+ /**
+ * When the browser window gets resized, relay the event to the front.
+ */
+ _onResize() {
+ this.emit("resize");
+ }
+
+ /**
+ * Ensures that the node is attached and it can be accessed from the root.
+ *
+ * @param {(Node|NodeActor)} nodes The nodes
+ * @return {Object} An object compatible with the disconnectedNode type.
+ */
+ attachElement(node) {
+ const { nodes, newParents } = this.attachElements([node]);
+ return {
+ node: nodes[0],
+ newParents,
+ };
+ }
+
+ /**
+ * Ensures that the nodes are attached and they can be accessed from the root.
+ *
+ * @param {(Node[]|NodeActor[])} nodes The nodes
+ * @return {Object} An object compatible with the disconnectedNodeArray type.
+ */
+ attachElements(nodes) {
+ const nodeActors = [];
+ const newParents = new Set();
+ for (let node of nodes) {
+ if (!(node instanceof NodeActor)) {
+ // If an anonymous node was passed in and we aren't supposed to know
+ // about it, then use the closest ancestor.
+ if (!this.showAllAnonymousContent) {
+ while (
+ node &&
+ standardTreeWalkerFilter(node) != nodeFilterConstants.FILTER_ACCEPT
+ ) {
+ node = this.rawParentNode(node);
+ }
+ if (!node) {
+ continue;
+ }
+ }
+
+ node = this._getOrCreateNodeActor(node);
+ }
+
+ this.ensurePathToRoot(node, newParents);
+ // If nodes may be an array of raw nodes, we're sure to only have
+ // NodeActors with the following array.
+ nodeActors.push(node);
+ }
+
+ return {
+ nodes: nodeActors,
+ newParents: [...newParents],
+ };
+ }
+
+ /**
+ * Return the document node that contains the given node,
+ * or the root node if no node is specified.
+ * @param NodeActor node
+ * The node whose document is needed, or null to
+ * return the root.
+ */
+ document(node) {
+ const doc = isNodeDead(node) ? this.rootDoc : nodeDocument(node.rawNode);
+ return this._getOrCreateNodeActor(doc);
+ }
+
+ /**
+ * Return the documentElement for the document containing the
+ * given node.
+ * @param NodeActor node
+ * The node whose documentElement is requested, or null
+ * to use the root document.
+ */
+ documentElement(node) {
+ const elt = isNodeDead(node)
+ ? this.rootDoc.documentElement
+ : nodeDocument(node.rawNode).documentElement;
+ return this._getOrCreateNodeActor(elt);
+ }
+
+ parentNode(node) {
+ const parent = this.rawParentNode(node);
+ if (parent) {
+ return this._getOrCreateNodeActor(parent);
+ }
+
+ return null;
+ }
+
+ rawParentNode(node) {
+ const rawNode = node instanceof NodeActor ? node.rawNode : node;
+ if (rawNode == this.rootDoc) {
+ return null;
+ }
+ return InspectorUtils.getParentForNode(rawNode, /* anonymous = */ true);
+ }
+
+ /**
+ * If the given NodeActor only has a single text node as a child with a text
+ * content small enough to be inlined, return that child's NodeActor.
+ *
+ * @param NodeActor node
+ */
+ inlineTextChild({ rawNode }) {
+ // Quick checks to prevent creating a new walker if possible.
+ if (
+ isMarkerPseudoElement(rawNode) ||
+ isBeforePseudoElement(rawNode) ||
+ isAfterPseudoElement(rawNode) ||
+ isShadowHost(rawNode) ||
+ rawNode.nodeType != Node.ELEMENT_NODE ||
+ !!rawNode.children.length ||
+ isFrameWithChildTarget(this.targetActor, rawNode) ||
+ isFrameBlockedByCSP(rawNode)
+ ) {
+ return undefined;
+ }
+
+ const children = this._rawChildren(rawNode, /* includeAssigned = */ true);
+ const firstChild = children[0];
+
+ // Bail out if:
+ // - more than one child
+ // - unique child is not a text node
+ // - unique child is a text node, but is too long to be inlined
+ // - we are a slot -> these are always represented on their own lines with
+ // a link to the original node.
+ // - we are a flex item -> these are always shown on their own lines so they can be
+ // selected by the flexbox inspector.
+ const isAssignedToSlot =
+ firstChild &&
+ rawNode.nodeName === "SLOT" &&
+ isDirectShadowHostChild(firstChild);
+
+ const isFlexItem = !!firstChild?.parentFlexElement;
+
+ if (
+ !firstChild ||
+ children.length > 1 ||
+ firstChild.nodeType !== Node.TEXT_NODE ||
+ firstChild.nodeValue.length > gValueSummaryLength ||
+ isAssignedToSlot ||
+ isFlexItem
+ ) {
+ return undefined;
+ }
+
+ return this._getOrCreateNodeActor(firstChild);
+ }
+
+ /**
+ * Mark a node as 'retained'.
+ *
+ * A retained node is not released when `releaseNode` is called on its
+ * parent, or when a parent is released with the `cleanup` option to
+ * `getMutations`.
+ *
+ * When a retained node's parent is released, a retained mode is added to
+ * the walker's "retained orphans" list.
+ *
+ * Retained nodes can be deleted by providing the `force` option to
+ * `releaseNode`. They will also be released when their document
+ * has been destroyed.
+ *
+ * Retaining a node makes no promise about its children; They can
+ * still be removed by normal means.
+ */
+ retainNode(node) {
+ node.retained = true;
+ }
+
+ /**
+ * Remove the 'retained' mark from a node. If the node was a
+ * retained orphan, release it.
+ */
+ unretainNode(node) {
+ node.retained = false;
+ if (this._retainedOrphans.has(node)) {
+ this._retainedOrphans.delete(node);
+ this.releaseNode(node);
+ }
+ }
+
+ /**
+ * Release actors for a node and all child nodes.
+ */
+ releaseNode(node, options = {}) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ if (node.retained && !options.force) {
+ this._retainedOrphans.add(node);
+ return;
+ }
+
+ if (node.retained) {
+ // Forcing a retained node to go away.
+ this._retainedOrphans.delete(node);
+ }
+
+ for (const child of this._rawChildren(node.rawNode)) {
+ const childActor = this.getNode(child);
+ if (childActor) {
+ this.releaseNode(childActor, options);
+ }
+ }
+
+ node.destroy();
+ }
+
+ /**
+ * Add any nodes between `node` and the walker's root node that have not
+ * yet been seen by the client.
+ */
+ ensurePathToRoot(node, newParents = new Set()) {
+ if (!node) {
+ return newParents;
+ }
+ let parent = this.rawParentNode(node);
+ while (parent) {
+ let parentActor = this.getNode(parent);
+ if (parentActor) {
+ // This parent did exist, so the client knows about it.
+ return newParents;
+ }
+ // This parent didn't exist, so hasn't been seen by the client yet.
+ parentActor = this._getOrCreateNodeActor(parent);
+ newParents.add(parentActor);
+ parent = this.rawParentNode(parentActor);
+ }
+ return newParents;
+ }
+
+ /**
+ * Return the number of children under the provided NodeActor.
+ *
+ * @param NodeActor node
+ * See JSDoc for children()
+ * @param object options
+ * See JSDoc for children()
+ * @return Number the number of children
+ */
+ countChildren(node, options = {}) {
+ return this._getChildren(node, options).nodes.length;
+ }
+
+ /**
+ * Return children of the given node. By default this method will return
+ * all children of the node, but there are options that can restrict this
+ * to a more manageable subset.
+ *
+ * @param NodeActor node
+ * The node whose children you're curious about.
+ * @param object options
+ * Named options:
+ * `maxNodes`: The set of nodes returned by the method will be no longer
+ * than maxNodes.
+ * `start`: If a node is specified, the list of nodes will start
+ * with the given child. Mutally exclusive with `center`.
+ * `center`: If a node is specified, the given node will be as centered
+ * as possible in the list, given how close to the ends of the child
+ * list it is. Mutually exclusive with `start`.
+ *
+ * @returns an object with three items:
+ * hasFirst: true if the first child of the node is included in the list.
+ * hasLast: true if the last child of the node is included in the list.
+ * nodes: Array of NodeActor representing the nodes returned by the request.
+ */
+ children(node, options = {}) {
+ const { hasFirst, hasLast, nodes } = this._getChildren(node, options);
+ return {
+ hasFirst,
+ hasLast,
+ nodes: nodes.map(n => this._getOrCreateNodeActor(n)),
+ };
+ }
+
+ /**
+ * Returns the raw children of the DOM node, with anon content filtered as needed
+ * @param Node rawNode.
+ * @param boolean includeAssigned
+ * Whether <slot> assigned children should be returned. See
+ * HTMLSlotElement.assignedNodes().
+ * @returns Array<Node> the list of children.
+ */
+ _rawChildren(rawNode, includeAssigned) {
+ const filter = this.showAllAnonymousContent
+ ? allAnonymousContentTreeWalkerFilter
+ : standardTreeWalkerFilter;
+ const ret = [];
+ const children = InspectorUtils.getChildrenForNode(
+ rawNode,
+ /* anonymous = */ true,
+ includeAssigned
+ );
+ for (const child of children) {
+ if (filter(child) == nodeFilterConstants.FILTER_ACCEPT) {
+ ret.push(child);
+ }
+ }
+ return ret;
+ }
+
+ /**
+ * Return chidlren of the given node. Contrary to children children(), this method only
+ * returns DOMNodes. Therefore it will not create NodeActor wrappers and will not
+ * update the nodeActors map for the discovered nodes either. This makes this method
+ * safe to call when you are not sure if the discovered nodes will be communicated to
+ * the client.
+ *
+ * @param NodeActor node
+ * See JSDoc for children()
+ * @param object options
+ * See JSDoc for children()
+ * @return an object with three items:
+ * hasFirst: true if the first child of the node is included in the list.
+ * hasLast: true if the last child of the node is included in the list.
+ * nodes: Array of DOMNodes.
+ */
+ // eslint-disable-next-line complexity
+ _getChildren(node, options = {}) {
+ if (isNodeDead(node) || isFrameBlockedByCSP(node.rawNode)) {
+ return { hasFirst: true, hasLast: true, nodes: [] };
+ }
+
+ if (options.center && options.start) {
+ throw Error("Can't specify both 'center' and 'start' options.");
+ }
+
+ let maxNodes = options.maxNodes || -1;
+ if (maxNodes == -1) {
+ maxNodes = Number.MAX_VALUE;
+ }
+
+ let nodes = this._rawChildren(node.rawNode, /* includeAssigned = */ true);
+ let hasFirst = true;
+ let hasLast = true;
+ if (nodes.length > maxNodes) {
+ let startIndex;
+ if (options.center) {
+ const centerIndex = nodes.indexOf(options.center.rawNode);
+ const backwardCount = Math.floor(maxNodes / 2);
+ // If centering would hit the end, just read the last maxNodes nodes.
+ if (centerIndex - backwardCount + maxNodes >= nodes.length) {
+ startIndex = nodes.length - maxNodes;
+ } else {
+ startIndex = Math.max(0, centerIndex - backwardCount);
+ }
+ } else if (options.start) {
+ startIndex = Math.max(0, nodes.indexOf(options.start.rawNode));
+ } else {
+ startIndex = 0;
+ }
+ const endIndex = Math.min(startIndex + maxNodes, nodes.length);
+ hasFirst = startIndex == 0;
+ hasLast = endIndex >= nodes.length;
+ nodes = nodes.slice(startIndex, endIndex);
+ }
+
+ return { hasFirst, hasLast, nodes };
+ }
+
+ /**
+ * Get the next sibling of a given node. Getting nodes one at a time
+ * might be inefficient, be careful.
+ */
+ nextSibling(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ const sibling = walker.nextSibling();
+ return sibling ? this._getOrCreateNodeActor(sibling) : null;
+ }
+
+ /**
+ * Get the previous sibling of a given node. Getting nodes one at a time
+ * might be inefficient, be careful.
+ */
+ previousSibling(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ const sibling = walker.previousSibling();
+ return sibling ? this._getOrCreateNodeActor(sibling) : null;
+ }
+
+ /**
+ * Helper function for the `children` method: Read forward in the sibling
+ * list into an array with `count` items, including the current node.
+ */
+ _readForward(walker, count) {
+ const ret = [];
+
+ let node = walker.currentNode;
+ do {
+ if (!walker.isSkippedNode(node)) {
+ // The walker can be on a node that would be filtered out if it didn't find any
+ // other node to fallback to.
+ ret.push(node);
+ }
+ node = walker.nextSibling();
+ } while (node && --count);
+ return ret;
+ }
+
+ /**
+ * Return the first node in the document that matches the given selector.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelector
+ *
+ * @param NodeActor baseNode
+ * @param string selector
+ */
+ querySelector(baseNode, selector) {
+ if (isNodeDead(baseNode)) {
+ return {};
+ }
+
+ const node = baseNode.rawNode.querySelector(selector);
+ if (!node) {
+ return {};
+ }
+
+ return this.attachElement(node);
+ }
+
+ /**
+ * Return a NodeListActor with all nodes that match the given selector.
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Element.querySelectorAll
+ *
+ * @param NodeActor baseNode
+ * @param string selector
+ */
+ querySelectorAll(baseNode, selector) {
+ let nodeList = null;
+
+ try {
+ nodeList = baseNode.rawNode.querySelectorAll(selector);
+ } catch (e) {
+ // Bad selector. Do nothing as the selector can come from a searchbox.
+ }
+
+ return new NodeListActor(this, nodeList);
+ }
+
+ /**
+ * Get a list of nodes that match the given selector in all known frames of
+ * the current content page.
+ * @param {String} selector.
+ * @return {Array}
+ */
+ _multiFrameQuerySelectorAll(selector) {
+ let nodes = [];
+
+ for (const { document } of this.targetActor.windows) {
+ try {
+ nodes = [...nodes, ...document.querySelectorAll(selector)];
+ } catch (e) {
+ // Bad selector. Do nothing as the selector can come from a searchbox.
+ }
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Get a list of nodes that match the given XPath in all known frames of
+ * the current content page.
+ * @param {String} xPath.
+ * @return {Array}
+ */
+ _multiFrameXPath(xPath) {
+ const nodes = [];
+
+ for (const window of this.targetActor.windows) {
+ const document = window.document;
+ try {
+ const result = document.evaluate(
+ xPath,
+ document.documentElement,
+ null,
+ window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
+ null
+ );
+
+ for (let i = 0; i < result.snapshotLength; i++) {
+ nodes.push(result.snapshotItem(i));
+ }
+ } catch (e) {
+ // Bad XPath. Do nothing as the XPath can come from a searchbox.
+ }
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Return a NodeListActor with all nodes that match the given XPath in all
+ * frames of the current content page.
+ * @param {String} xPath
+ */
+ multiFrameXPath(xPath) {
+ return new NodeListActor(this, this._multiFrameXPath(xPath));
+ }
+
+ /**
+ * Search the document for a given string.
+ * Results will be searched with the walker-search module (searches through
+ * tag names, attribute names and values, and text contents).
+ *
+ * @returns {searchresult}
+ * - {NodeList} list
+ * - {Array<Object>} metadata. Extra information with indices that
+ * match up with node list.
+ */
+ search(query) {
+ const results = this.walkerSearch.search(query);
+ const nodeList = new NodeListActor(
+ this,
+ results.map(r => r.node)
+ );
+
+ return {
+ list: nodeList,
+ metadata: [],
+ };
+ }
+
+ /**
+ * Returns a list of matching results for CSS selector autocompletion.
+ *
+ * @param string query
+ * The selector query being completed
+ * @param string completing
+ * The exact token being completed out of the query
+ * @param string selectorState
+ * One of "pseudo", "id", "tag", "class", "null"
+ */
+ // eslint-disable-next-line complexity
+ getSuggestionsForQuery(query, completing, selectorState) {
+ const sugs = {
+ classes: new Map(),
+ tags: new Map(),
+ ids: new Map(),
+ };
+ let result = [];
+ let nodes = null;
+ // Filtering and sorting the results so that protocol transfer is miminal.
+ switch (selectorState) {
+ case "pseudo":
+ result = PSEUDO_SELECTORS.filter(item => {
+ return item[0].startsWith(":" + completing);
+ });
+ break;
+
+ case "class":
+ if (!query) {
+ nodes = this._multiFrameQuerySelectorAll("[class]");
+ } else {
+ nodes = this._multiFrameQuerySelectorAll(query);
+ }
+ for (const node of nodes) {
+ for (const className of node.classList) {
+ sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1);
+ }
+ }
+ sugs.classes.delete("");
+ sugs.classes.delete(HIDDEN_CLASS);
+ for (const [className, count] of sugs.classes) {
+ if (className.startsWith(completing)) {
+ result.push(["." + CSS.escape(className), count, selectorState]);
+ }
+ }
+ break;
+
+ case "id":
+ if (!query) {
+ nodes = this._multiFrameQuerySelectorAll("[id]");
+ } else {
+ nodes = this._multiFrameQuerySelectorAll(query);
+ }
+ for (const node of nodes) {
+ sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1);
+ }
+ for (const [id, count] of sugs.ids) {
+ if (id.startsWith(completing) && id !== "") {
+ result.push(["#" + CSS.escape(id), count, selectorState]);
+ }
+ }
+ break;
+
+ case "tag":
+ if (!query) {
+ nodes = this._multiFrameQuerySelectorAll("*");
+ } else {
+ nodes = this._multiFrameQuerySelectorAll(query);
+ }
+ for (const node of nodes) {
+ const tag = node.localName;
+ sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1);
+ }
+ for (const [tag, count] of sugs.tags) {
+ if (new RegExp("^" + completing + ".*", "i").test(tag)) {
+ result.push([tag, count, selectorState]);
+ }
+ }
+
+ // For state 'tag' (no preceding # or .) and when there's no query (i.e.
+ // only one word) then search for the matching classes and ids
+ if (!query) {
+ result = [
+ ...result,
+ ...this.getSuggestionsForQuery(null, completing, "class")
+ .suggestions,
+ ...this.getSuggestionsForQuery(null, completing, "id").suggestions,
+ ];
+ }
+
+ break;
+
+ case "null":
+ nodes = this._multiFrameQuerySelectorAll(query);
+ for (const node of nodes) {
+ sugs.ids.set(node.id, (sugs.ids.get(node.id) | 0) + 1);
+ const tag = node.localName;
+ sugs.tags.set(tag, (sugs.tags.get(tag) | 0) + 1);
+ for (const className of node.classList) {
+ sugs.classes.set(className, (sugs.classes.get(className) | 0) + 1);
+ }
+ }
+ for (const [tag, count] of sugs.tags) {
+ tag && result.push([tag, count]);
+ }
+ for (const [id, count] of sugs.ids) {
+ id && result.push(["#" + id, count]);
+ }
+ sugs.classes.delete("");
+ sugs.classes.delete(HIDDEN_CLASS);
+ for (const [className, count] of sugs.classes) {
+ className && result.push(["." + className, count]);
+ }
+ }
+
+ // Sort by count (desc) and name (asc)
+ result = result.sort((a, b) => {
+ // Computed a sortable string with first the inverted count, then the name
+ let sortA = 10000 - a[1] + a[0];
+ let sortB = 10000 - b[1] + b[0];
+
+ // Prefixing ids, classes and tags, to group results
+ const firstA = a[0].substring(0, 1);
+ const firstB = b[0].substring(0, 1);
+
+ const getSortKeyPrefix = firstLetter => {
+ if (firstLetter === "#") {
+ return "2";
+ }
+ if (firstLetter === ".") {
+ return "1";
+ }
+ return "0";
+ };
+
+ sortA = getSortKeyPrefix(firstA) + sortA;
+ sortB = getSortKeyPrefix(firstB) + sortB;
+
+ // String compare
+ return sortA.localeCompare(sortB);
+ });
+
+ result = result.slice(0, 25);
+
+ return {
+ query,
+ suggestions: result,
+ };
+ }
+
+ /**
+ * Add a pseudo-class lock to a node.
+ *
+ * @param NodeActor node
+ * @param string pseudo
+ * A pseudoclass: ':hover', ':active', ':focus', ':focus-within'
+ * @param options
+ * Options object:
+ * `parents`: True if the pseudo-class should be added
+ * to parent nodes.
+ * `enabled`: False if the pseudo-class should be locked
+ * to 'off'. Defaults to true.
+ *
+ * @returns An empty packet. A "pseudoClassLock" mutation will
+ * be queued for any changed nodes.
+ */
+ addPseudoClassLock(node, pseudo, options = {}) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ // There can be only one node locked per pseudo, so dismiss all existing
+ // ones
+ for (const locked of this._activePseudoClassLocks) {
+ if (InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo)) {
+ this._removePseudoClassLock(locked, pseudo);
+ }
+ }
+
+ const enabled = options.enabled === undefined || options.enabled;
+ this._addPseudoClassLock(node, pseudo, enabled);
+
+ if (!options.parents) {
+ return;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ let cur;
+ while ((cur = walker.parentNode())) {
+ const curNode = this._getOrCreateNodeActor(cur);
+ this._addPseudoClassLock(curNode, pseudo, enabled);
+ }
+ }
+
+ _queuePseudoClassMutation(node) {
+ this.queueMutation({
+ target: node.actorID,
+ type: "pseudoClassLock",
+ pseudoClassLocks: node.writePseudoClassLocks(),
+ });
+ }
+
+ _addPseudoClassLock(node, pseudo, enabled) {
+ if (node.rawNode.nodeType !== Node.ELEMENT_NODE) {
+ return false;
+ }
+ InspectorUtils.addPseudoClassLock(node.rawNode, pseudo, enabled);
+ this._activePseudoClassLocks.add(node);
+ this._queuePseudoClassMutation(node);
+ return true;
+ }
+
+ hideNode(node) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ loadSheet(node.rawNode.ownerGlobal, HELPER_SHEET);
+ node.rawNode.classList.add(HIDDEN_CLASS);
+ }
+
+ unhideNode(node) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ node.rawNode.classList.remove(HIDDEN_CLASS);
+ }
+
+ /**
+ * Remove a pseudo-class lock from a node.
+ *
+ * @param NodeActor node
+ * @param string pseudo
+ * A pseudoclass: ':hover', ':active', ':focus', ':focus-within'
+ * @param options
+ * Options object:
+ * `parents`: True if the pseudo-class should be removed
+ * from parent nodes.
+ *
+ * @returns An empty response. "pseudoClassLock" mutations
+ * will be emitted for any changed nodes.
+ */
+ removePseudoClassLock(node, pseudo, options = {}) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ this._removePseudoClassLock(node, pseudo);
+
+ // Remove pseudo class for children as we don't want to allow
+ // turning it on for some childs without setting it on some parents
+ for (const locked of this._activePseudoClassLocks) {
+ if (
+ node.rawNode.contains(locked.rawNode) &&
+ InspectorUtils.hasPseudoClassLock(locked.rawNode, pseudo)
+ ) {
+ this._removePseudoClassLock(locked, pseudo);
+ }
+ }
+
+ if (!options.parents) {
+ return;
+ }
+
+ const walker = this.getDocumentWalker(node.rawNode);
+ let cur;
+ while ((cur = walker.parentNode())) {
+ const curNode = this._getOrCreateNodeActor(cur);
+ this._removePseudoClassLock(curNode, pseudo);
+ }
+ }
+
+ _removePseudoClassLock(node, pseudo) {
+ if (node.rawNode.nodeType != Node.ELEMENT_NODE) {
+ return false;
+ }
+ InspectorUtils.removePseudoClassLock(node.rawNode, pseudo);
+ if (!node.writePseudoClassLocks()) {
+ this._activePseudoClassLocks.delete(node);
+ }
+
+ this._queuePseudoClassMutation(node);
+ return true;
+ }
+
+ /**
+ * Clear all the pseudo-classes on a given node or all nodes.
+ * @param {NodeActor} node Optional node to clear pseudo-classes on
+ */
+ clearPseudoClassLocks(node) {
+ if (node && isNodeDead(node)) {
+ return;
+ }
+
+ if (node) {
+ InspectorUtils.clearPseudoClassLocks(node.rawNode);
+ this._activePseudoClassLocks.delete(node);
+ this._queuePseudoClassMutation(node);
+ } else {
+ for (const locked of this._activePseudoClassLocks) {
+ InspectorUtils.clearPseudoClassLocks(locked.rawNode);
+ this._activePseudoClassLocks.delete(locked);
+ this._queuePseudoClassMutation(locked);
+ }
+ }
+ }
+
+ /**
+ * Get a node's innerHTML property.
+ */
+ innerHTML(node) {
+ let html = "";
+ if (!isNodeDead(node)) {
+ html = node.rawNode.innerHTML;
+ }
+ return new LongStringActor(this.conn, html);
+ }
+
+ /**
+ * Set a node's innerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ * @param {string} value The piece of HTML content.
+ */
+ setInnerHTML(node, value) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ if (
+ rawNode.nodeType !== rawNode.ownerDocument.ELEMENT_NODE &&
+ rawNode.nodeType !== rawNode.ownerDocument.DOCUMENT_FRAGMENT_NODE
+ ) {
+ throw new Error("Can only change innerHTML to element or fragment nodes");
+ }
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.innerHTML = value;
+ }
+
+ /**
+ * Get a node's outerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ */
+ outerHTML(node) {
+ let outerHTML = "";
+ if (!isNodeDead(node)) {
+ outerHTML = node.rawNode.outerHTML;
+ }
+ return new LongStringActor(this.conn, outerHTML);
+ }
+
+ /**
+ * Set a node's outerHTML property.
+ *
+ * @param {NodeActor} node The node.
+ * @param {string} value The piece of HTML content.
+ */
+ setOuterHTML(node, value) {
+ if (isNodeDead(node)) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ const doc = nodeDocument(rawNode);
+ const win = doc.defaultView;
+ let parser;
+ if (!win) {
+ throw new Error("The window object shouldn't be null");
+ } else {
+ // We create DOMParser under window object because we want a content
+ // DOMParser, which means all the DOM objects created by this DOMParser
+ // will be in the same DocGroup as rawNode.parentNode. Then the newly
+ // created nodes can be adopted into rawNode.parentNode.
+ parser = new win.DOMParser();
+ }
+
+ const mimeType = rawNode.tagName === "svg" ? "image/svg+xml" : "text/html";
+ const parsedDOM = parser.parseFromString(value, mimeType);
+ const parentNode = rawNode.parentNode;
+
+ // Special case for head and body. Setting document.body.outerHTML
+ // creates an extra <head> tag, and document.head.outerHTML creates
+ // an extra <body>. So instead we will call replaceChild with the
+ // parsed DOM, assuming that they aren't trying to set both tags at once.
+ if (rawNode.tagName === "BODY") {
+ if (parsedDOM.head.innerHTML === "") {
+ parentNode.replaceChild(parsedDOM.body, rawNode);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ } else if (rawNode.tagName === "HEAD") {
+ if (parsedDOM.body.innerHTML === "") {
+ parentNode.replaceChild(parsedDOM.head, rawNode);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ } else if (node.isDocumentElement()) {
+ // Unable to set outerHTML on the document element. Fall back by
+ // setting attributes manually. Then replace all the child nodes.
+ const finalAttributeModifications = [];
+ const attributeModifications = {};
+ for (const attribute of rawNode.attributes) {
+ attributeModifications[attribute.name] = null;
+ }
+ for (const attribute of parsedDOM.documentElement.attributes) {
+ attributeModifications[attribute.name] = attribute.value;
+ }
+ for (const key in attributeModifications) {
+ finalAttributeModifications.push({
+ attributeName: key,
+ newValue: attributeModifications[key],
+ });
+ }
+ node.modifyAttributes(finalAttributeModifications);
+
+ rawNode.replaceChildren(...parsedDOM.firstElementChild.childNodes);
+ } else {
+ // eslint-disable-next-line no-unsanitized/property
+ rawNode.outerHTML = value;
+ }
+ }
+
+ /**
+ * Insert adjacent HTML to a node.
+ *
+ * @param {Node} node
+ * @param {string} position One of "beforeBegin", "afterBegin", "beforeEnd",
+ * "afterEnd" (see Element.insertAdjacentHTML).
+ * @param {string} value The HTML content.
+ */
+ insertAdjacentHTML(node, position, value) {
+ if (isNodeDead(node)) {
+ return { node: [], newParents: [] };
+ }
+
+ const rawNode = node.rawNode;
+ const isInsertAsSibling =
+ position === "beforeBegin" || position === "afterEnd";
+
+ // Don't insert anything adjacent to the document element.
+ if (isInsertAsSibling && node.isDocumentElement()) {
+ throw new Error("Can't insert adjacent element to the root.");
+ }
+
+ const rawParentNode = rawNode.parentNode;
+ if (!rawParentNode && isInsertAsSibling) {
+ throw new Error("Can't insert as sibling without parent node.");
+ }
+
+ // We can't use insertAdjacentHTML, because we want to return the nodes
+ // being created (so the front can remove them if the user undoes
+ // the change). So instead, use Range.createContextualFragment().
+ const range = rawNode.ownerDocument.createRange();
+ if (position === "beforeBegin" || position === "afterEnd") {
+ range.selectNode(rawNode);
+ } else {
+ range.selectNodeContents(rawNode);
+ }
+ // eslint-disable-next-line no-unsanitized/method
+ const docFrag = range.createContextualFragment(value);
+ const newRawNodes = Array.from(docFrag.childNodes);
+ switch (position) {
+ case "beforeBegin":
+ rawParentNode.insertBefore(docFrag, rawNode);
+ break;
+ case "afterEnd":
+ // Note: if the second argument is null, rawParentNode.insertBefore
+ // behaves like rawParentNode.appendChild.
+ rawParentNode.insertBefore(docFrag, rawNode.nextSibling);
+ break;
+ case "afterBegin":
+ rawNode.insertBefore(docFrag, rawNode.firstChild);
+ break;
+ case "beforeEnd":
+ rawNode.appendChild(docFrag);
+ break;
+ default:
+ throw new Error(
+ "Invalid position value. Must be either " +
+ "'beforeBegin', 'beforeEnd', 'afterBegin' or 'afterEnd'."
+ );
+ }
+
+ return this.attachElements(newRawNodes);
+ }
+
+ /**
+ * Duplicate a specified node
+ *
+ * @param {NodeActor} node The node to duplicate.
+ */
+ duplicateNode({ rawNode }) {
+ const clonedNode = rawNode.cloneNode(true);
+ rawNode.parentNode.insertBefore(clonedNode, rawNode.nextSibling);
+ }
+
+ /**
+ * Test whether a node is a document or a document element.
+ *
+ * @param {NodeActor} node The node to remove.
+ * @return {boolean} True if the node is a document or a document element.
+ */
+ isDocumentOrDocumentElementNode(node) {
+ return (
+ (node.rawNode.ownerDocument &&
+ node.rawNode.ownerDocument.documentElement === this.rawNode) ||
+ node.rawNode.nodeType === Node.DOCUMENT_NODE
+ );
+ }
+
+ /**
+ * Removes a node from its parent node.
+ *
+ * @param {NodeActor} node The node to remove.
+ * @returns The node's nextSibling before it was removed.
+ */
+ removeNode(node) {
+ if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
+ throw Error("Cannot remove document, document elements or dead nodes.");
+ }
+
+ const nextSibling = this.nextSibling(node);
+ node.rawNode.remove();
+ // Mutation events will take care of the rest.
+ return nextSibling;
+ }
+
+ /**
+ * Removes an array of nodes from their parent node.
+ *
+ * @param {NodeActor[]} nodes The nodes to remove.
+ */
+ removeNodes(nodes) {
+ // Check that all nodes are valid before processing the removals.
+ for (const node of nodes) {
+ if (isNodeDead(node) || this.isDocumentOrDocumentElementNode(node)) {
+ throw Error("Cannot remove document, document elements or dead nodes");
+ }
+ }
+
+ for (const node of nodes) {
+ node.rawNode.remove();
+ // Mutation events will take care of the rest.
+ }
+ }
+
+ /**
+ * Insert a node into the DOM.
+ */
+ insertBefore(node, parent, sibling) {
+ if (
+ isNodeDead(node) ||
+ isNodeDead(parent) ||
+ (sibling && isNodeDead(sibling))
+ ) {
+ return;
+ }
+
+ const rawNode = node.rawNode;
+ const rawParent = parent.rawNode;
+ const rawSibling = sibling ? sibling.rawNode : null;
+
+ // Don't bother inserting a node if the document position isn't going
+ // to change. This prevents needless iframes reloading and mutations.
+ if (rawNode.parentNode === rawParent) {
+ let currentNextSibling = this.nextSibling(node);
+ currentNextSibling = currentNextSibling
+ ? currentNextSibling.rawNode
+ : null;
+
+ if (rawNode === rawSibling || currentNextSibling === rawSibling) {
+ return;
+ }
+ }
+
+ rawParent.insertBefore(rawNode, rawSibling);
+ }
+
+ /**
+ * Editing a node's tagname actually means creating a new node with the same
+ * attributes, removing the node and inserting the new one instead.
+ * This method does not return anything as mutation events are taking care of
+ * informing the consumers about changes.
+ */
+ editTagName(node, tagName) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const oldNode = node.rawNode;
+
+ // Create a new element with the same attributes as the current element and
+ // prepare to replace the current node with it.
+ let newNode;
+ try {
+ newNode = nodeDocument(oldNode).createElement(tagName);
+ } catch (x) {
+ // Failed to create a new element with that tag name, ignore the change,
+ // and signal the error to the front.
+ return Promise.reject(
+ new Error("Could not change node's tagName to " + tagName)
+ );
+ }
+
+ const attrs = oldNode.attributes;
+ for (let i = 0; i < attrs.length; i++) {
+ newNode.setAttribute(attrs[i].name, attrs[i].value);
+ }
+
+ // Insert the new node, and transfer the old node's children.
+ oldNode.parentNode.insertBefore(newNode, oldNode);
+ while (oldNode.firstChild) {
+ newNode.appendChild(oldNode.firstChild);
+ }
+
+ oldNode.remove();
+ return null;
+ }
+
+ /**
+ * Gets the state of the mutation breakpoint types for this actor.
+ *
+ * @param {NodeActor} node The node to get breakpoint info for.
+ */
+ getMutationBreakpoints(node) {
+ let bps;
+ if (!isNodeDead(node)) {
+ bps = this._breakpointInfoForNode(node.rawNode);
+ }
+
+ return (
+ bps || {
+ subtree: false,
+ removal: false,
+ attribute: false,
+ }
+ );
+ }
+
+ /**
+ * Set the state of some subset of mutation breakpoint types for this actor.
+ *
+ * @param {NodeActor} node The node to set breakpoint info for.
+ * @param {Object} bps A subset of the breakpoints for this actor that
+ * should be updated to new states.
+ */
+ setMutationBreakpoints(node, bps) {
+ if (isNodeDead(node)) {
+ return;
+ }
+ const rawNode = node.rawNode;
+
+ if (
+ rawNode.ownerDocument &&
+ rawNode.getRootNode({ composed: true }) != rawNode.ownerDocument
+ ) {
+ // We only allow watching for mutations on nodes that are attached to
+ // documents. That allows us to clean up our mutation listeners when all
+ // of the watched nodes have been removed from the document.
+ return;
+ }
+
+ // This argument has nullable fields so we want to only update boolean
+ // field values.
+ const bpsForNode = Object.keys(bps).reduce((obj, bp) => {
+ if (typeof bps[bp] === "boolean") {
+ obj[bp] = bps[bp];
+ }
+ return obj;
+ }, {});
+
+ this._updateMutationBreakpointState("api", rawNode, {
+ ...this.getMutationBreakpoints(node),
+ ...bpsForNode,
+ });
+ }
+
+ /**
+ * Update the mutation breakpoint state for the given DOM node.
+ *
+ * @param {Node} rawNode The DOM node.
+ * @param {Object} bpsForNode The state of each mutation bp type we support.
+ */
+ _updateMutationBreakpointState(mutationReason, rawNode, bpsForNode) {
+ const rawDoc = rawNode.ownerDocument || rawNode;
+
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(
+ rawDoc,
+ true /* createIfNeeded */
+ );
+ let originalBpsForNode = this._breakpointInfoForNode(rawNode);
+
+ if (!bpsForNode && !originalBpsForNode) {
+ return;
+ }
+
+ bpsForNode = bpsForNode || {};
+ originalBpsForNode = originalBpsForNode || {};
+
+ if (Object.values(bpsForNode).some(Boolean)) {
+ docMutationBreakpoints.nodes.set(rawNode, bpsForNode);
+ } else {
+ docMutationBreakpoints.nodes.delete(rawNode);
+ }
+ if (originalBpsForNode.subtree && !bpsForNode.subtree) {
+ docMutationBreakpoints.counts.subtree -= 1;
+ } else if (!originalBpsForNode.subtree && bpsForNode.subtree) {
+ docMutationBreakpoints.counts.subtree += 1;
+ }
+
+ if (originalBpsForNode.removal && !bpsForNode.removal) {
+ docMutationBreakpoints.counts.removal -= 1;
+ } else if (!originalBpsForNode.removal && bpsForNode.removal) {
+ docMutationBreakpoints.counts.removal += 1;
+ }
+
+ if (originalBpsForNode.attribute && !bpsForNode.attribute) {
+ docMutationBreakpoints.counts.attribute -= 1;
+ } else if (!originalBpsForNode.attribute && bpsForNode.attribute) {
+ docMutationBreakpoints.counts.attribute += 1;
+ }
+
+ this._updateDocumentMutationListeners(rawDoc);
+
+ const actor = this.getNode(rawNode);
+ if (actor) {
+ this.queueMutation({
+ target: actor.actorID,
+ type: "mutationBreakpoint",
+ mutationBreakpoints: this.getMutationBreakpoints(actor),
+ mutationReason,
+ });
+ }
+ }
+
+ /**
+ * Controls whether this DOM document has event listeners attached for
+ * handling of DOM mutation breakpoints.
+ *
+ * @param {Document} rawDoc The DOM document.
+ */
+ _updateDocumentMutationListeners(rawDoc) {
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(rawDoc);
+ if (!docMutationBreakpoints) {
+ rawDoc.devToolsWatchingDOMMutations = false;
+ return;
+ }
+
+ const anyBreakpoint =
+ docMutationBreakpoints.counts.subtree > 0 ||
+ docMutationBreakpoints.counts.removal > 0 ||
+ docMutationBreakpoints.counts.attribute > 0;
+
+ rawDoc.devToolsWatchingDOMMutations = anyBreakpoint;
+
+ if (docMutationBreakpoints.counts.subtree > 0) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolschildinserted",
+ this.onSubtreeModified,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolschildinserted",
+ this.onSubtreeModified,
+ true /* capture */
+ );
+ }
+
+ if (anyBreakpoint) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolschildremoved",
+ this.onNodeRemoved,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolschildremoved",
+ this.onNodeRemoved,
+ true /* capture */
+ );
+ }
+
+ if (docMutationBreakpoints.counts.attribute > 0) {
+ this.chromeEventHandler.addEventListener(
+ "devtoolsattrmodified",
+ this.onAttributeModified,
+ true /* capture */
+ );
+ } else {
+ this.chromeEventHandler.removeEventListener(
+ "devtoolsattrmodified",
+ this.onAttributeModified,
+ true /* capture */
+ );
+ }
+ }
+
+ _breakOnMutation(mutationType, targetNode, ancestorNode, action) {
+ this.targetActor.threadActor.pauseForMutationBreakpoint(
+ mutationType,
+ targetNode,
+ ancestorNode,
+ action
+ );
+ }
+
+ _mutationBreakpointsForDoc(rawDoc, createIfNeeded = false) {
+ let docMutationBreakpoints = this._mutationBreakpoints.get(rawDoc);
+ if (!docMutationBreakpoints && createIfNeeded) {
+ docMutationBreakpoints = {
+ counts: {
+ subtree: 0,
+ removal: 0,
+ attribute: 0,
+ },
+ nodes: new Map(),
+ };
+ this._mutationBreakpoints.set(rawDoc, docMutationBreakpoints);
+ }
+ return docMutationBreakpoints;
+ }
+
+ _breakpointInfoForNode(target) {
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(
+ target.ownerDocument || target
+ );
+ return (
+ (docMutationBreakpoints && docMutationBreakpoints.nodes.get(target)) ||
+ null
+ );
+ }
+
+ onNodeRemoved(evt) {
+ const mutationBpInfo = this._breakpointInfoForNode(evt.target);
+ const hasNodeRemovalEvent = mutationBpInfo?.removal;
+
+ this._clearMutationBreakpointsFromSubtree(evt.target);
+ if (hasNodeRemovalEvent) {
+ this._breakOnMutation("nodeRemoved", evt.target);
+ } else {
+ this.onSubtreeModified(evt);
+ }
+ }
+
+ onAttributeModified(evt) {
+ const mutationBpInfo = this._breakpointInfoForNode(evt.target);
+ if (mutationBpInfo?.attribute) {
+ this._breakOnMutation("attributeModified", evt.target);
+ }
+ }
+
+ onSubtreeModified(evt) {
+ const action = evt.type === "devtoolschildinserted" ? "add" : "remove";
+ let node = evt.target;
+ if (node.isNativeAnonymous && !this.showAllAnonymousContent) {
+ return;
+ }
+ while ((node = node.parentNode) !== null) {
+ const mutationBpInfo = this._breakpointInfoForNode(node);
+ if (mutationBpInfo?.subtree) {
+ this._breakOnMutation("subtreeModified", evt.target, node, action);
+ break;
+ }
+ }
+ }
+
+ _clearMutationBreakpointsFromSubtree(targetNode) {
+ const targetDoc = targetNode.ownerDocument || targetNode;
+ const docMutationBreakpoints = this._mutationBreakpointsForDoc(targetDoc);
+ if (!docMutationBreakpoints || docMutationBreakpoints.nodes.size === 0) {
+ // Bail early for performance. If the doc has no mutation BPs, there is
+ // no reason to iterate through the children looking for things to detach.
+ return;
+ }
+
+ // The walker is not limited to the subtree of the argument node, so we
+ // need to ensure that we stop walking when we leave the subtree.
+ const nextWalkerSibling = this._getNextTraversalSibling(targetNode);
+
+ const walker = new DocumentWalker(targetNode, this.rootWin, {
+ filter: noAnonymousContentTreeWalkerFilter,
+ skipTo: SKIP_TO_SIBLING,
+ });
+
+ do {
+ this._updateMutationBreakpointState("detach", walker.currentNode, null);
+ } while (walker.nextNode() && walker.currentNode !== nextWalkerSibling);
+ }
+
+ _getNextTraversalSibling(targetNode) {
+ const walker = new DocumentWalker(targetNode, this.rootWin, {
+ filter: noAnonymousContentTreeWalkerFilter,
+ skipTo: SKIP_TO_SIBLING,
+ });
+
+ while (!walker.nextSibling()) {
+ if (!walker.parentNode()) {
+ // If we try to step past the walker root, there is no next sibling.
+ return null;
+ }
+ }
+ return walker.currentNode;
+ }
+
+ /**
+ * Get any pending mutation records. Must be called by the client after
+ * the `new-mutations` notification is received. Returns an array of
+ * mutation records.
+ *
+ * Mutation records have a basic structure:
+ *
+ * {
+ * type: attributes|characterData|childList,
+ * target: <domnode actor ID>,
+ * }
+ *
+ * And additional attributes based on the mutation type:
+ *
+ * `attributes` type:
+ * attributeName: <string> - the attribute that changed
+ * attributeNamespace: <string> - the attribute's namespace URI, if any.
+ * newValue: <string> - The new value of the attribute, if any.
+ *
+ * `characterData` type:
+ * newValue: <string> - the new nodeValue for the node
+ *
+ * `childList` type is returned when the set of children for a node
+ * has changed. Includes extra data, which can be used by the client to
+ * maintain its ownership subtree.
+ *
+ * added: array of <domnode actor ID> - The list of actors *previously
+ * seen by the client* that were added to the target node.
+ * removed: array of <domnode actor ID> The list of actors *previously
+ * seen by the client* that were removed from the target node.
+ * inlineTextChild: If the node now has a single text child, it will
+ * be sent here.
+ *
+ * Actors that are included in a MutationRecord's `removed` but
+ * not in an `added` have been removed from the client's ownership
+ * tree (either by being moved under a node the client has seen yet
+ * or by being removed from the tree entirely), and is considered
+ * 'orphaned'.
+ *
+ * Keep in mind that if a node that the client hasn't seen is moved
+ * into or out of the target node, it will not be included in the
+ * removedNodes and addedNodes list, so if the client is interested
+ * in the new set of children it needs to issue a `children` request.
+ */
+ getMutations(options = {}) {
+ const pending = this._pendingMutations || [];
+ this._pendingMutations = [];
+ this._waitingForGetMutations = false;
+
+ if (options.cleanup) {
+ for (const node of this._orphaned) {
+ // Release the orphaned node. Nodes or children that have been
+ // retained will be moved to this._retainedOrphans.
+ this.releaseNode(node);
+ }
+ this._orphaned = new Set();
+ }
+
+ return pending;
+ }
+
+ queueMutation(mutation) {
+ if (!this.actorID || this._destroyed) {
+ // We've been destroyed, don't bother queueing this mutation.
+ return;
+ }
+
+ // Add the mutation to the list of mutations to be retrieved next.
+ this._pendingMutations.push(mutation);
+
+ // Bail out if we already emitted a new-mutations event and are waiting for a client
+ // to retrieve them.
+ if (this._waitingForGetMutations) {
+ return;
+ }
+
+ if (IMMEDIATE_MUTATIONS.includes(mutation.type)) {
+ this._emitNewMutations();
+ } else {
+ /**
+ * If many mutations are fired at the same time, clients might sequentially request
+ * children/siblings for updated nodes, which can be costly. By throttling the calls
+ * to getMutations, duplicated mutations will be ignored.
+ */
+ this._throttledEmitNewMutations();
+ }
+ }
+
+ _emitNewMutations() {
+ if (!this.actorID || this._destroyed) {
+ // Bail out if the actor was destroyed after throttling this call.
+ return;
+ }
+
+ if (this._waitingForGetMutations || !this._pendingMutations.length) {
+ // Bail out if we already fired the new-mutation event or if no mutations are
+ // waiting to be retrieved.
+ return;
+ }
+
+ this._waitingForGetMutations = true;
+ this.emit("new-mutations");
+ }
+
+ /**
+ * Handles mutations from the DOM mutation observer API.
+ *
+ * @param array[MutationRecord] mutations
+ * See https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver#MutationRecord
+ */
+ onMutations(mutations) {
+ // Notify any observers that want *all* mutations (even on nodes that aren't
+ // referenced). This is not sent over the protocol so can only be used by
+ // scripts running in the server process.
+ this.emit("any-mutation");
+
+ for (const change of mutations) {
+ const targetActor = this.getNode(change.target);
+ if (!targetActor) {
+ continue;
+ }
+ const targetNode = change.target;
+ const type = change.type;
+ const mutation = {
+ type,
+ target: targetActor.actorID,
+ };
+
+ if (type === "attributes") {
+ mutation.attributeName = change.attributeName;
+ mutation.attributeNamespace = change.attributeNamespace || undefined;
+ mutation.newValue = targetNode.hasAttribute(mutation.attributeName)
+ ? targetNode.getAttribute(mutation.attributeName)
+ : null;
+ } else if (type === "characterData") {
+ mutation.newValue = targetNode.nodeValue;
+ this._maybeQueueInlineTextChildMutation(change, targetNode);
+ } else if (type === "childList") {
+ // Get the list of removed and added actors that the client has seen
+ // so that it can keep its ownership tree up to date.
+ const removedActors = [];
+ const addedActors = [];
+ for (const removed of change.removedNodes) {
+ const removedActor = this.getNode(removed);
+ if (!removedActor) {
+ // If the client never encountered this actor we don't need to
+ // mention that it was removed.
+ continue;
+ }
+ // While removed from the tree, nodes are saved as orphaned.
+ this._orphaned.add(removedActor);
+ removedActors.push(removedActor.actorID);
+ }
+ for (const added of change.addedNodes) {
+ const addedActor = this.getNode(added);
+ if (!addedActor) {
+ // If the client never encounted this actor we don't need to tell
+ // it about its addition for ownership tree purposes - if the
+ // client wants to see the new nodes it can ask for children.
+ continue;
+ }
+ // The actor is reconnected to the ownership tree, unorphan
+ // it and let the client know so that its ownership tree is up
+ // to date.
+ this._orphaned.delete(addedActor);
+ addedActors.push(addedActor.actorID);
+ }
+
+ mutation.numChildren = targetActor.numChildren;
+ mutation.removed = removedActors;
+ mutation.added = addedActors;
+
+ const inlineTextChild = this.inlineTextChild(targetActor);
+ if (inlineTextChild) {
+ mutation.inlineTextChild = inlineTextChild.form();
+ }
+ }
+ this.queueMutation(mutation);
+ }
+ }
+
+ /**
+ * Check if the provided mutation could change the way the target element is
+ * inlined with its parent node. If it might, a custom mutation of type
+ * "inlineTextChild" will be queued.
+ *
+ * @param {MutationRecord} mutation
+ * A characterData type mutation
+ */
+ _maybeQueueInlineTextChildMutation(mutation) {
+ const { oldValue, target } = mutation;
+ const newValue = target.nodeValue;
+ const limit = gValueSummaryLength;
+
+ if (
+ (oldValue.length <= limit && newValue.length <= limit) ||
+ (oldValue.length > limit && newValue.length > limit)
+ ) {
+ // Bail out if the new & old values are both below/above the size limit.
+ return;
+ }
+
+ const parentActor = this.getNode(target.parentNode);
+ if (!parentActor || parentActor.rawNode.children.length) {
+ // If the parent node has other children, a character data mutation will
+ // not change anything regarding inlining text nodes.
+ return;
+ }
+
+ const inlineTextChild = this.inlineTextChild(parentActor);
+ this.queueMutation({
+ type: "inlineTextChild",
+ target: parentActor.actorID,
+ inlineTextChild: inlineTextChild ? inlineTextChild.form() : undefined,
+ });
+ }
+
+ onSlotchange(event) {
+ const target = event.target;
+ const targetActor = this.getNode(target);
+ if (!targetActor) {
+ return;
+ }
+
+ this.queueMutation({
+ type: "slotchange",
+ target: targetActor.actorID,
+ });
+ }
+
+ /**
+ * Fires when an anonymous root is created.
+ * This is needed because regular mutation observers don't fire on some kinds
+ * of NAC creation. We want to treat this like a regular insertion.
+ */
+ onAnonymousrootcreated(event) {
+ const root = event.target;
+ const parent = this.rawParentNode(root);
+ if (!parent) {
+ // These events are async. The node might have been removed already, in
+ // which case there's nothing to do anymore.
+ return;
+ }
+ // By the time onAnonymousrootremoved fires, the node is already detached
+ // from its parent, so we need to remember it by hand.
+ this._anonParents.set(root, parent);
+ this.onMutations([
+ {
+ type: "childList",
+ target: parent,
+ addedNodes: [root],
+ removedNodes: [],
+ },
+ ]);
+ }
+
+ /**
+ * @see onAnonymousrootcreated
+ */
+ onAnonymousrootremoved(event) {
+ const root = event.target;
+ const parent = this._anonParents.get(root);
+ if (!parent) {
+ return;
+ }
+ this._anonParents.delete(root);
+ this.onMutations([
+ {
+ type: "childList",
+ target: parent,
+ addedNodes: [],
+ removedNodes: [root],
+ },
+ ]);
+ }
+
+ onShadowrootattached(event) {
+ const actor = this.getNode(event.target);
+ if (!actor) {
+ return;
+ }
+
+ const mutation = {
+ type: "shadowRootAttached",
+ target: actor.actorID,
+ };
+ this.queueMutation(mutation);
+ }
+
+ onFrameLoad({ window, isTopLevel }) {
+ // By the time we receive the DOMContentLoaded event, we might have been destroyed
+ if (this._destroyed) {
+ return;
+ }
+ const { readyState } = window.document;
+ if (readyState != "interactive" && readyState != "complete") {
+ // The document is not loaded, so we want to register to fire again when the
+ // DOM has been loaded.
+ window.addEventListener(
+ "DOMContentLoaded",
+ this.onFrameLoad.bind(this, { window, isTopLevel }),
+ { once: true }
+ );
+ return;
+ }
+
+ window.document.shadowRootAttachedEventEnabled = true;
+
+ if (isTopLevel) {
+ // If we initialize the inspector while the document is loading,
+ // we may already have a root document set in the constructor.
+ if (
+ this.rootDoc &&
+ this.rootDoc !== window.document &&
+ !Cu.isDeadWrapper(this.rootDoc) &&
+ this.rootDoc.defaultView
+ ) {
+ this.onFrameUnload({ window: this.rootDoc.defaultView });
+ }
+ // Update all DOM objects references to target the new document.
+ this.rootWin = window;
+ this.rootDoc = window.document;
+ this.rootNode = this.document();
+ this.emit("root-available", this.rootNode);
+ } else {
+ const frame = getFrameElement(window);
+ const frameActor = this.getNode(frame);
+ if (frameActor) {
+ // If the parent frame is in the map of known node actors, create the
+ // actor for the new document and emit a root-available event.
+ const documentActor = this._getOrCreateNodeActor(window.document);
+ this.emit("root-available", documentActor);
+ }
+ }
+ }
+
+ // Returns true if domNode is in window or a subframe.
+ _childOfWindow(window, domNode) {
+ while (domNode) {
+ const win = nodeDocument(domNode).defaultView;
+ if (win === window) {
+ return true;
+ }
+ domNode = getFrameElement(win);
+ }
+ return false;
+ }
+
+ onFrameUnload({ window }) {
+ // Any retained orphans that belong to this document
+ // or its children need to be released, and a mutation sent
+ // to notify of that.
+ const releasedOrphans = [];
+
+ for (const retained of this._retainedOrphans) {
+ if (
+ Cu.isDeadWrapper(retained.rawNode) ||
+ this._childOfWindow(window, retained.rawNode)
+ ) {
+ this._retainedOrphans.delete(retained);
+ releasedOrphans.push(retained.actorID);
+ this.releaseNode(retained, { force: true });
+ }
+ }
+
+ if (releasedOrphans.length) {
+ this.queueMutation({
+ target: this.rootNode.actorID,
+ type: "unretained",
+ nodes: releasedOrphans,
+ });
+ }
+
+ const doc = window.document;
+ const documentActor = this.getNode(doc);
+ if (!documentActor) {
+ return;
+ }
+
+ // Removing a frame also removes any mutation breakpoints set on that
+ // document so that clients can clear their set of active breakpoints.
+ const mutationBps = this._mutationBreakpointsForDoc(doc);
+ const nodes = mutationBps ? Array.from(mutationBps.nodes.keys()) : [];
+ for (const node of nodes) {
+ this._updateMutationBreakpointState("unload", node, null);
+ }
+
+ this.emit("root-destroyed", documentActor);
+
+ // Cleanup root doc references if we just unloaded the top level root
+ // document.
+ if (this.rootDoc === doc) {
+ this.rootDoc = null;
+ this.rootNode = null;
+ }
+
+ // Release the actor for the unloaded document.
+ this.releaseNode(documentActor, { force: true });
+ }
+
+ /**
+ * Check if a node is attached to the DOM tree of the current page.
+ * @param {Node} rawNode
+ * @return {Boolean} false if the node is removed from the tree or within a
+ * document fragment
+ */
+ _isInDOMTree(rawNode) {
+ let walker;
+ try {
+ walker = this.getDocumentWalker(rawNode);
+ } catch (e) {
+ // The DocumentWalker may throw NS_ERROR_ILLEGAL_VALUE when the node isn't found as a legit children of its parent
+ // ex: <iframe> manually added as immediate child of another <iframe>
+ if (e.name == "NS_ERROR_ILLEGAL_VALUE") {
+ return false;
+ }
+ throw e;
+ }
+ let current = walker.currentNode;
+
+ // Reaching the top of tree
+ while (walker.parentNode()) {
+ current = walker.currentNode;
+ }
+
+ // The top of the tree is a fragment or is not rootDoc, hence rawNode isn't
+ // attached
+ if (
+ current.nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
+ current !== this.rootDoc
+ ) {
+ return false;
+ }
+
+ // Otherwise the top of the tree is rootDoc, hence rawNode is in rootDoc
+ return true;
+ }
+
+ /**
+ * @see _isInDomTree
+ */
+ isInDOMTree(node) {
+ if (isNodeDead(node)) {
+ return false;
+ }
+ return this._isInDOMTree(node.rawNode);
+ }
+
+ /**
+ * Given a windowID return the NodeActor for the corresponding frameElement,
+ * unless it's the root window
+ */
+ getNodeActorFromWindowID(windowID) {
+ let win;
+
+ try {
+ win = Services.wm.getOuterWindowWithId(windowID);
+ } catch (e) {
+ // ignore
+ }
+
+ if (!win) {
+ return {
+ error: "noWindow",
+ message: "The related docshell is destroyed or not found",
+ };
+ } else if (!win.frameElement) {
+ // the frame element of the root document is privileged & thus
+ // inaccessible, so return the document body/element instead
+ return this.attachElement(
+ win.document.body || win.document.documentElement
+ );
+ }
+
+ return this.attachElement(win.frameElement);
+ }
+
+ /**
+ * Given a contentDomReference return the NodeActor for the corresponding frameElement.
+ */
+ getNodeActorFromContentDomReference(contentDomReference) {
+ let rawNode = lazy.ContentDOMReference.resolve(contentDomReference);
+ if (!rawNode || !this._isInDOMTree(rawNode)) {
+ return null;
+ }
+
+ // This is a special case for the document object whereby it is considered
+ // as document.documentElement (the <html> node)
+ if (rawNode.defaultView && rawNode === rawNode.defaultView.document) {
+ rawNode = rawNode.documentElement;
+ }
+
+ return this.attachElement(rawNode);
+ }
+
+ /**
+ * Given a StyleSheet resource ID, commonly used in the style-editor, get its
+ * ownerNode and return the corresponding walker's NodeActor.
+ * Note that getNodeFromActor was added later and can now be used instead.
+ */
+ getStyleSheetOwnerNode(resourceId) {
+ const manager = this.targetActor.getStyleSheetsManager();
+ const ownerNode = manager.getOwnerNode(resourceId);
+ return this.attachElement(ownerNode);
+ }
+
+ /**
+ * This method can be used to retrieve NodeActor for DOM nodes from other
+ * actors in a way that they can later be highlighted in the page, or
+ * selected in the inspector.
+ * If an actor has a reference to a DOM node, and the UI needs to know about
+ * this DOM node (and possibly select it in the inspector), the UI should
+ * first retrieve a reference to the walkerFront:
+ *
+ * // Make sure the inspector/walker have been initialized first.
+ * const inspectorFront = await toolbox.target.getFront("inspector");
+ * // Retrieve the walker.
+ * const walker = inspectorFront.walker;
+ *
+ * And then call this method:
+ *
+ * // Get the nodeFront from my actor, passing the ID and properties path.
+ * walker.getNodeFromActor(myActorID, ["element"]).then(nodeFront => {
+ * // Use the nodeFront, e.g. select the node in the inspector.
+ * toolbox.getPanel("inspector").selection.setNodeFront(nodeFront);
+ * });
+ *
+ * @param {String} actorID The ID for the actor that has a reference to the
+ * DOM node.
+ * @param {Array} path Where, on the actor, is the DOM node stored. If in the
+ * scope of the actor, the node is available as `this.data.node`, then this
+ * should be ["data", "node"].
+ * @return {NodeActor} The attached NodeActor, or null if it couldn't be
+ * found.
+ */
+ getNodeFromActor(actorID, path) {
+ const actor = this.conn.getActor(actorID);
+ if (!actor) {
+ return null;
+ }
+
+ let obj = actor;
+ for (const name of path) {
+ if (!(name in obj)) {
+ return null;
+ }
+ obj = obj[name];
+ }
+
+ return this.attachElement(obj);
+ }
+
+ /**
+ * Returns an instance of the LayoutActor that is used to retrieve CSS layout-related
+ * information.
+ *
+ * @return {LayoutActor}
+ */
+ getLayoutInspector() {
+ if (!this.layoutActor) {
+ this.layoutActor = new LayoutActor(this.conn, this.targetActor, this);
+ }
+
+ return this.layoutActor;
+ }
+
+ /**
+ * Returns the parent grid DOMNode of the given node if it exists, otherwise, it
+ * returns null.
+ */
+ getParentGridNode(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const parentGridNode = findGridParentContainerForNode(node.rawNode);
+ return parentGridNode ? this._getOrCreateNodeActor(parentGridNode) : null;
+ }
+
+ /**
+ * Returns the offset parent DOMNode of the given node if it exists, otherwise, it
+ * returns null.
+ */
+ getOffsetParent(node) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ const offsetParent = node.rawNode.offsetParent;
+
+ if (!offsetParent) {
+ return null;
+ }
+
+ return this._getOrCreateNodeActor(offsetParent);
+ }
+
+ getEmbedderElement(browsingContextID) {
+ const browsingContext = BrowsingContext.get(browsingContextID);
+ let rawNode = browsingContext.embedderElement;
+ if (!this._isInDOMTree(rawNode)) {
+ return null;
+ }
+
+ // This is a special case for the document object whereby it is considered
+ // as document.documentElement (the <html> node)
+ if (rawNode.defaultView && rawNode === rawNode.defaultView.document) {
+ rawNode = rawNode.documentElement;
+ }
+
+ return this.attachElement(rawNode);
+ }
+
+ pick(doFocus, isLocalTab) {
+ this.nodePicker.pick(doFocus, isLocalTab);
+ }
+
+ cancelPick() {
+ this.nodePicker.cancelPick();
+ }
+
+ clearPicker() {
+ this.nodePicker.resetHoveredNodeReference();
+ }
+
+ /**
+ * Given a scrollable node, find its descendants which are causing overflow in it and
+ * add their raw nodes to the map as keys with the scrollable element as the values.
+ *
+ * @param {NodeActor} scrollableNode A scrollable node.
+ * @param {Map} map The map to which the overflow causing elements are added.
+ */
+ updateOverflowCausingElements(scrollableNode, map) {
+ if (
+ isNodeDead(scrollableNode) ||
+ scrollableNode.rawNode.nodeType !== Node.ELEMENT_NODE
+ ) {
+ return;
+ }
+
+ const overflowCausingChildren = [
+ ...InspectorUtils.getOverflowingChildrenOfElement(scrollableNode.rawNode),
+ ];
+
+ for (let overflowCausingChild of overflowCausingChildren) {
+ // overflowCausingChild is a Node, but not necessarily an Element.
+ // So, get the containing Element
+ if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) {
+ overflowCausingChild = overflowCausingChild.parentElement;
+ }
+ map.set(overflowCausingChild, scrollableNode);
+ }
+ }
+
+ /**
+ * Returns an array of the overflow causing elements' NodeActor for the given node.
+ *
+ * @param {NodeActor} node The scrollable node.
+ * @return {Array<NodeActor>} An array of the overflow causing elements.
+ */
+ getOverflowCausingElements(node) {
+ if (
+ isNodeDead(node) ||
+ node.rawNode.nodeType !== Node.ELEMENT_NODE ||
+ !node.isScrollable
+ ) {
+ return [];
+ }
+
+ const overflowCausingElements = [
+ ...InspectorUtils.getOverflowingChildrenOfElement(node.rawNode),
+ ].map(overflowCausingChild => {
+ if (overflowCausingChild.nodeType !== Node.ELEMENT_NODE) {
+ overflowCausingChild = overflowCausingChild.parentElement;
+ }
+
+ return overflowCausingChild;
+ });
+
+ return this.attachElements(overflowCausingElements);
+ }
+
+ /**
+ * Return the scrollable ancestor node which has overflow because of the given node.
+ *
+ * @param {NodeActor} overflowCausingNode
+ */
+ getScrollableAncestorNode(overflowCausingNode) {
+ if (
+ isNodeDead(overflowCausingNode) ||
+ !this.overflowCausingElementsMap.has(overflowCausingNode.rawNode)
+ ) {
+ return null;
+ }
+
+ return this.overflowCausingElementsMap.get(overflowCausingNode.rawNode);
+ }
+}
+
+exports.WalkerActor = WalkerActor;
diff --git a/devtools/server/actors/layout.js b/devtools/server/actors/layout.js
new file mode 100644
index 0000000000..d046a6ca17
--- /dev/null
+++ b/devtools/server/actors/layout.js
@@ -0,0 +1,518 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ flexboxSpec,
+ flexItemSpec,
+ gridSpec,
+ layoutSpec,
+} = require("resource://devtools/shared/specs/layout.js");
+
+const {
+ getStringifiableFragments,
+} = require("resource://devtools/server/actors/utils/css-grid-utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "findGridParentContainerForNode",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getCSSStyleRules",
+ "resource://devtools/shared/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isCssPropertyKnown",
+ "resource://devtools/server/actors/css-properties.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "parseDeclarations",
+ "resource://devtools/shared/css/parsing-utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "nodeConstants",
+ "resource://devtools/shared/dom-node-constants.js"
+);
+
+/**
+ * Set of actors the expose the CSS layout information to the devtools protocol clients.
+ *
+ * The |Layout| actor is the main entry point. It is used to get various CSS
+ * layout-related information from the document.
+ *
+ * The |Flexbox| actor provides the container node information to inspect the flexbox
+ * container. It is also used to return an array of |FlexItem| actors which provide the
+ * flex item information.
+ *
+ * The |Grid| actor provides the grid fragment information to inspect the grid container.
+ */
+
+class FlexboxActor extends Actor {
+ /**
+ * @param {LayoutActor} layoutActor
+ * The LayoutActor instance.
+ * @param {DOMNode} containerEl
+ * The flex container element.
+ */
+ constructor(layoutActor, containerEl) {
+ super(layoutActor.conn, flexboxSpec);
+
+ this.containerEl = containerEl;
+ this.walker = layoutActor.walker;
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.containerEl = null;
+ this.walker = null;
+ }
+
+ form() {
+ const styles = CssLogic.getComputedStyle(this.containerEl);
+
+ const form = {
+ actor: this.actorID,
+ // The computed style properties of the flex container.
+ properties: {
+ "align-content": styles.alignContent,
+ "align-items": styles.alignItems,
+ "flex-direction": styles.flexDirection,
+ "flex-wrap": styles.flexWrap,
+ "justify-content": styles.justifyContent,
+ },
+ };
+
+ // If the WalkerActor already knows the container element, then also return its
+ // ActorID so we avoid the client from doing another round trip to get it in many
+ // cases.
+ if (this.walker.hasNode(this.containerEl)) {
+ form.containerNodeActorID = this.walker.getNode(this.containerEl).actorID;
+ }
+
+ return form;
+ }
+
+ /**
+ * Returns an array of FlexItemActor objects for all the flex item elements contained
+ * in the flex container element.
+ *
+ * @return {Array}
+ * An array of FlexItemActor objects.
+ */
+ getFlexItems() {
+ if (isNodeDead(this.containerEl)) {
+ return [];
+ }
+
+ const flex = this.containerEl.getAsFlexContainer();
+ if (!flex) {
+ return [];
+ }
+
+ const flexItemActors = [];
+ const { crossAxisDirection, mainAxisDirection } = flex;
+
+ for (const line of flex.getLines()) {
+ for (const item of line.getItems()) {
+ flexItemActors.push(
+ new FlexItemActor(this, item.node, {
+ crossAxisDirection,
+ mainAxisDirection,
+ crossMaxSize: item.crossMaxSize,
+ crossMinSize: item.crossMinSize,
+ mainBaseSize: item.mainBaseSize,
+ mainDeltaSize: item.mainDeltaSize,
+ mainMaxSize: item.mainMaxSize,
+ mainMinSize: item.mainMinSize,
+ lineGrowthState: line.growthState,
+ clampState: item.clampState,
+ })
+ );
+ }
+ }
+
+ return flexItemActors;
+ }
+}
+
+/**
+ * The FlexItemActor provides information about a flex items' data.
+ */
+class FlexItemActor extends Actor {
+ /**
+ * @param {FlexboxActor} flexboxActor
+ * The FlexboxActor instance.
+ * @param {DOMNode} element
+ * The flex item element.
+ * @param {Object} flexItemSizing
+ * The flex item sizing data.
+ */
+ constructor(flexboxActor, element, flexItemSizing) {
+ super(flexboxActor.conn, flexItemSpec);
+
+ this.containerEl = flexboxActor.containerEl;
+ this.element = element;
+ this.flexItemSizing = flexItemSizing;
+ this.walker = flexboxActor.walker;
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.containerEl = null;
+ this.element = null;
+ this.flexItemSizing = null;
+ this.walker = null;
+ }
+
+ form() {
+ const { mainAxisDirection } = this.flexItemSizing;
+ const dimension = mainAxisDirection.startsWith("horizontal")
+ ? "width"
+ : "height";
+
+ // Find the authored sizing properties for this item.
+ const properties = {
+ "flex-basis": "",
+ "flex-grow": "",
+ "flex-shrink": "",
+ [`min-${dimension}`]: "",
+ [`max-${dimension}`]: "",
+ [dimension]: "",
+ };
+
+ const isElementNode = this.element.nodeType === this.element.ELEMENT_NODE;
+
+ if (isElementNode) {
+ for (const name in properties) {
+ const values = [];
+ const cssRules = getCSSStyleRules(this.element);
+
+ for (const rule of cssRules) {
+ // For each rule, go through *all* properties, because there may be several of
+ // them in the same rule and some with !important flags (which would be more
+ // important even if placed before another property with the same name)
+ const declarations = parseDeclarations(
+ isCssPropertyKnown,
+ rule.style.cssText
+ );
+
+ for (const declaration of declarations) {
+ if (declaration.name === name && declaration.value !== "auto") {
+ values.push({
+ value: declaration.value,
+ priority: declaration.priority,
+ });
+ }
+ }
+ }
+
+ // Then go through the element style because it's usually more important, but
+ // might not be if there is a prior !important property
+ if (
+ this.element.style &&
+ this.element.style[name] &&
+ this.element.style[name] !== "auto"
+ ) {
+ values.push({
+ value: this.element.style.getPropertyValue(name),
+ priority: this.element.style.getPropertyPriority(name),
+ });
+ }
+
+ // Now that we have a list of all the property's rule values, go through all the
+ // values and show the property value with the highest priority. Therefore, show
+ // the last !important value. Otherwise, show the last value stored.
+ let rulePropertyValue = "";
+
+ if (values.length) {
+ const lastValueIndex = values.length - 1;
+ rulePropertyValue = values[lastValueIndex].value;
+
+ for (const { priority, value } of values) {
+ if (priority === "important") {
+ rulePropertyValue = `${value} !important`;
+ }
+ }
+ }
+
+ properties[name] = rulePropertyValue;
+ }
+ }
+
+ // Also find some computed sizing properties that will be useful for this item.
+ const { flexGrow, flexShrink } = isElementNode
+ ? CssLogic.getComputedStyle(this.element)
+ : { flexGrow: null, flexShrink: null };
+ const computedStyle = { flexGrow, flexShrink };
+
+ const form = {
+ actor: this.actorID,
+ // The flex item sizing data.
+ flexItemSizing: this.flexItemSizing,
+ // The authored style properties of the flex item.
+ properties,
+ // The computed style properties of the flex item.
+ computedStyle,
+ };
+
+ // If the WalkerActor already knows the flex item element, then also return its
+ // ActorID so we avoid the client from doing another round trip to get it in many
+ // cases.
+ if (this.walker.hasNode(this.element)) {
+ form.nodeActorID = this.walker.getNode(this.element).actorID;
+ }
+
+ return form;
+ }
+}
+
+/**
+ * The GridActor provides information about a given grid's fragment data.
+ */
+class GridActor extends Actor {
+ /**
+ * @param {LayoutActor} layoutActor
+ * The LayoutActor instance.
+ * @param {DOMNode} containerEl
+ * The grid container element.
+ */
+ constructor(layoutActor, containerEl) {
+ super(layoutActor.conn, gridSpec);
+
+ this.containerEl = containerEl;
+ this.walker = layoutActor.walker;
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.containerEl = null;
+ this.gridFragments = null;
+ this.walker = null;
+ }
+
+ form() {
+ // Seralize the grid fragment data into JSON so protocol.js knows how to write
+ // and read the data.
+ const gridFragments = this.containerEl.getGridFragments();
+ this.gridFragments = getStringifiableFragments(gridFragments);
+
+ // Record writing mode and text direction for use by the grid outline.
+ const { direction, gridTemplateColumns, gridTemplateRows, writingMode } =
+ CssLogic.getComputedStyle(this.containerEl);
+
+ const form = {
+ actor: this.actorID,
+ direction,
+ gridFragments: this.gridFragments,
+ writingMode,
+ };
+
+ // If the WalkerActor already knows the container element, then also return its
+ // ActorID so we avoid the client from doing another round trip to get it in many
+ // cases.
+ if (this.walker.hasNode(this.containerEl)) {
+ form.containerNodeActorID = this.walker.getNode(this.containerEl).actorID;
+ }
+
+ form.isSubgrid =
+ gridTemplateRows.startsWith("subgrid") ||
+ gridTemplateColumns.startsWith("subgrid");
+
+ return form;
+ }
+}
+
+/**
+ * The CSS layout actor provides layout information for the given document.
+ */
+class LayoutActor extends Actor {
+ constructor(conn, targetActor, walker) {
+ super(conn, layoutSpec);
+
+ this.targetActor = targetActor;
+ this.walker = walker;
+ }
+
+ destroy() {
+ super.destroy();
+
+ this.targetActor = null;
+ this.walker = null;
+ }
+
+ /**
+ * Helper function for getAsFlexItem, getCurrentGrid and getCurrentFlexbox. Returns the
+ * grid or flex container (whichever is requested) found by iterating on the given
+ * selected node. The current node can be a grid/flex container or grid/flex item.
+ * If it is a grid/flex item, returns the parent grid/flex container. Otherwise, returns
+ * null if the current or parent node is not a grid/flex container.
+ *
+ * @param {Node|NodeActor} node
+ * The node to start iterating at.
+ * @param {String} type
+ * Can be "grid" or "flex", the display type we are searching for.
+ * @param {Boolean} onlyLookAtContainer
+ * If true, only look at given node's container and iterate from there.
+ * @return {GridActor|FlexboxActor|null}
+ * The GridActor or FlexboxActor of the grid/flex container of the given node.
+ * Otherwise, returns null.
+ */
+ getCurrentDisplay(node, type, onlyLookAtContainer) {
+ if (isNodeDead(node)) {
+ return null;
+ }
+
+ // Given node can either be a Node or a NodeActor.
+ if (node.rawNode) {
+ node = node.rawNode;
+ }
+
+ const flexType = type === "flex";
+ const gridType = type === "grid";
+ const displayType = this.walker.getNode(node).displayType;
+
+ // If the node is an element, check first if it is itself a flex or a grid.
+ if (node.nodeType === node.ELEMENT_NODE) {
+ if (!displayType) {
+ return null;
+ }
+
+ if (flexType && displayType.includes("flex")) {
+ if (!onlyLookAtContainer) {
+ return new FlexboxActor(this, node);
+ }
+
+ const container = node.parentFlexElement;
+ if (container) {
+ return new FlexboxActor(this, container);
+ }
+
+ return null;
+ } else if (gridType && displayType.includes("grid")) {
+ return new GridActor(this, node);
+ }
+ }
+
+ // Otherwise, check if this is a flex/grid item or the parent node is a flex/grid
+ // container.
+ // Note that text nodes that are children of flex/grid containers are wrapped in
+ // anonymous containers, so even if their displayType getter returns null we still
+ // want to walk up the chain to find their container.
+ const parentFlexElement = node.parentFlexElement;
+ if (parentFlexElement && flexType) {
+ return new FlexboxActor(this, parentFlexElement);
+ }
+ const container = findGridParentContainerForNode(node);
+ if (container && gridType) {
+ return new GridActor(this, container);
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the grid container for a given selected node.
+ * The node itself can be a container, but if not, walk up the DOM to find its
+ * container.
+ * Returns null if no container can be found.
+ *
+ * @param {Node|NodeActor} node
+ * The node to start iterating at.
+ * @return {GridActor|null}
+ * The GridActor of the grid container of the given node. Otherwise, returns
+ * null.
+ */
+ getCurrentGrid(node) {
+ return this.getCurrentDisplay(node, "grid");
+ }
+
+ /**
+ * Returns the flex container for a given selected node.
+ * The node itself can be a container, but if not, walk up the DOM to find its
+ * container.
+ * Returns null if no container can be found.
+ *
+ * @param {Node|NodeActor} node
+ * The node to start iterating at.
+ * @param {Boolean|null} onlyLookAtParents
+ * If true, skip the passed node and only start looking at its parent and up.
+ * @return {FlexboxActor|null}
+ * The FlexboxActor of the flex container of the given node. Otherwise, returns
+ * null.
+ */
+ getCurrentFlexbox(node, onlyLookAtParents) {
+ return this.getCurrentDisplay(node, "flex", onlyLookAtParents);
+ }
+
+ /**
+ * Returns an array of GridActor objects for all the grid elements contained in the
+ * given root node.
+ *
+ * @param {Node|NodeActor} node
+ * The root node for grid elements
+ * @return {Array} An array of GridActor objects.
+ */
+ getGrids(node) {
+ if (isNodeDead(node)) {
+ return [];
+ }
+
+ // Root node can either be a Node or a NodeActor.
+ if (node.rawNode) {
+ node = node.rawNode;
+ }
+
+ // Root node can be a #document object, which does not support getElementsWithGrid.
+ if (node.nodeType === nodeConstants.DOCUMENT_NODE) {
+ node = node.documentElement;
+ }
+
+ if (!node) {
+ return [];
+ }
+
+ const gridElements = node.getElementsWithGrid();
+ let gridActors = gridElements.map(n => new GridActor(this, n));
+
+ if (this.targetActor.ignoreSubFrames) {
+ return gridActors;
+ }
+
+ const frames = node.querySelectorAll("iframe, frame");
+ for (const frame of frames) {
+ gridActors = gridActors.concat(this.getGrids(frame.contentDocument));
+ }
+
+ return gridActors;
+ }
+}
+
+function isNodeDead(node) {
+ return !node || (node.rawNode && Cu.isDeadWrapper(node.rawNode));
+}
+
+exports.FlexboxActor = FlexboxActor;
+exports.FlexItemActor = FlexItemActor;
+exports.GridActor = GridActor;
+exports.LayoutActor = LayoutActor;
diff --git a/devtools/server/actors/manifest.js b/devtools/server/actors/manifest.js
new file mode 100644
index 0000000000..5436d4a53a
--- /dev/null
+++ b/devtools/server/actors/manifest.js
@@ -0,0 +1,40 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ manifestSpec,
+} = require("resource://devtools/shared/specs/manifest.js");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ManifestObtainer: "resource://gre/modules/ManifestObtainer.sys.mjs",
+});
+
+/**
+ * An actor for a Web Manifest
+ */
+class ManifestActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, manifestSpec);
+ this.targetActor = targetActor;
+ }
+
+ async fetchCanonicalManifest() {
+ try {
+ const manifest = await lazy.ManifestObtainer.contentObtainManifest(
+ this.targetActor.window,
+ { checkConformance: true }
+ );
+ return { manifest };
+ } catch (error) {
+ return { manifest: null, errorMessage: error.message };
+ }
+ }
+}
+
+exports.ManifestActor = ManifestActor;
diff --git a/devtools/server/actors/memory.js b/devtools/server/actors/memory.js
new file mode 100644
index 0000000000..482596ed4a
--- /dev/null
+++ b/devtools/server/actors/memory.js
@@ -0,0 +1,90 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { memorySpec } = require("resource://devtools/shared/specs/memory.js");
+
+const { Memory } = require("resource://devtools/server/performance/memory.js");
+const {
+ actorBridgeWithSpec,
+} = require("resource://devtools/server/actors/common.js");
+
+loader.lazyRequireGetter(
+ this,
+ "StackFrameCache",
+ "resource://devtools/server/actors/utils/stack.js",
+ true
+);
+
+/**
+ * An actor that returns memory usage data for its parent actor's window.
+ * A target-scoped instance of this actor will measure the memory footprint of
+ * the target, such as a tab. A global-scoped instance however, will measure the memory
+ * footprint of the chrome window referenced by the root actor.
+ *
+ * This actor wraps the Memory module at devtools/server/performance/memory.js
+ * and provides RDP definitions.
+ *
+ * @see devtools/server/performance/memory.js for documentation.
+ */
+exports.MemoryActor = class MemoryActor extends Actor {
+ constructor(conn, parent, frameCache = new StackFrameCache()) {
+ super(conn, memorySpec);
+
+ this._onGarbageCollection = this._onGarbageCollection.bind(this);
+ this._onAllocations = this._onAllocations.bind(this);
+ this.bridge = new Memory(parent, frameCache);
+ this.bridge.on("garbage-collection", this._onGarbageCollection);
+ this.bridge.on("allocations", this._onAllocations);
+ }
+
+ destroy() {
+ this.bridge.off("garbage-collection", this._onGarbageCollection);
+ this.bridge.off("allocations", this._onAllocations);
+ this.bridge.destroy();
+ super.destroy();
+ }
+
+ attach = actorBridgeWithSpec("attach");
+
+ detach = actorBridgeWithSpec("detach");
+
+ getState = actorBridgeWithSpec("getState");
+
+ saveHeapSnapshot(boundaries) {
+ return this.bridge.saveHeapSnapshot(boundaries);
+ }
+
+ takeCensus = actorBridgeWithSpec("takeCensus");
+
+ startRecordingAllocations = actorBridgeWithSpec("startRecordingAllocations");
+
+ stopRecordingAllocations = actorBridgeWithSpec("stopRecordingAllocations");
+
+ getAllocationsSettings = actorBridgeWithSpec("getAllocationsSettings");
+
+ getAllocations = actorBridgeWithSpec("getAllocations");
+
+ forceGarbageCollection = actorBridgeWithSpec("forceGarbageCollection");
+
+ forceCycleCollection = actorBridgeWithSpec("forceCycleCollection");
+
+ measure = actorBridgeWithSpec("measure");
+
+ residentUnique = actorBridgeWithSpec("residentUnique");
+
+ _onGarbageCollection(data) {
+ if (this.conn.transport) {
+ this.emit("garbage-collection", data);
+ }
+ }
+
+ _onAllocations(data) {
+ if (this.conn.transport) {
+ this.emit("allocations", data);
+ }
+ }
+};
diff --git a/devtools/server/actors/moz.build b/devtools/server/actors/moz.build
new file mode 100644
index 0000000000..45af465249
--- /dev/null
+++ b/devtools/server/actors/moz.build
@@ -0,0 +1,90 @@
+# -*- 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 += [
+ "accessibility",
+ "addon",
+ "compatibility",
+ "descriptors",
+ "emulation",
+ "highlighters",
+ "inspector",
+ "network-monitor",
+ "object",
+ "resources",
+ "targets",
+ "utils",
+ "watcher",
+ "webconsole",
+ "worker",
+]
+
+DevToolsModules(
+ "animation-type-longhand.js",
+ "animation.js",
+ "array-buffer.js",
+ "blackboxing.js",
+ "breakpoint-list.js",
+ "breakpoint.js",
+ "changes.js",
+ "common.js",
+ "css-properties.js",
+ "device.js",
+ "environment.js",
+ "errordocs.js",
+ "frame.js",
+ "heap-snapshot-file.js",
+ "highlighters.js",
+ "layout.js",
+ "manifest.js",
+ "memory.js",
+ "object.js",
+ "objects-manager.js",
+ "page-style.js",
+ "pause-scoped.js",
+ "perf.js",
+ "preference.js",
+ "process.js",
+ "reflow.js",
+ "root.js",
+ "screenshot-content.js",
+ "screenshot.js",
+ "source.js",
+ "string.js",
+ "style-rule.js",
+ "style-sheets.js",
+ "target-configuration.js",
+ "thread-configuration.js",
+ "thread.js",
+ "tracer.js",
+ "watcher.js",
+ "webbrowser.js",
+ "webconsole.js",
+)
+
+with Files("animation.js"):
+ BUG_COMPONENT = ("DevTools", "Inspector: Animations")
+
+with Files("breakpoint.js"):
+ BUG_COMPONENT = ("DevTools", "Debugger")
+
+with Files("css-properties.js"):
+ BUG_COMPONENT = ("DevTools", "Inspector: Rules")
+
+with Files("memory.js"):
+ BUG_COMPONENT = ("DevTools", "Memory")
+
+with Files("performance*"):
+ BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)")
+
+with Files("source.js"):
+ BUG_COMPONENT = ("DevTools", "Debugger")
+
+with Files("stylesheets.js"):
+ BUG_COMPONENT = ("DevTools", "Style Editor")
+
+with Files("webconsole.js"):
+ BUG_COMPONENT = ("DevTools", "Console")
diff --git a/devtools/server/actors/network-monitor/channel-event-sink.js b/devtools/server/actors/network-monitor/channel-event-sink.js
new file mode 100644
index 0000000000..8ff00302f9
--- /dev/null
+++ b/devtools/server/actors/network-monitor/channel-event-sink.js
@@ -0,0 +1,99 @@
+/* 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 { ComponentUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ComponentUtils.sys.mjs"
+);
+
+/**
+ * This is a nsIChannelEventSink implementation that monitors channel redirects and
+ * informs the registered "collectors" about the old and new channels.
+ */
+const SINK_CLASS_DESCRIPTION = "NetworkMonitor Channel Event Sink";
+const SINK_CLASS_ID = Components.ID("{e89fa076-c845-48a8-8c45-2604729eba1d}");
+const SINK_CONTRACT_ID = "@mozilla.org/network/monitor/channeleventsink;1";
+const SINK_CATEGORY_NAME = "net-channel-event-sinks";
+
+class ChannelEventSink {
+ constructor() {
+ this.wrappedJSObject = this;
+ this.collectors = new Set();
+ }
+
+ QueryInterface = ChromeUtils.generateQI(["nsIChannelEventSink"]);
+
+ registerCollector(collector) {
+ this.collectors.add(collector);
+ }
+
+ unregisterCollector(collector) {
+ this.collectors.delete(collector);
+
+ if (this.collectors.size == 0) {
+ ChannelEventSinkFactory.unregister();
+ }
+ }
+
+ // eslint-disable-next-line no-shadow
+ asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
+ for (const collector of this.collectors) {
+ try {
+ collector.onChannelRedirect(oldChannel, newChannel, flags);
+ } catch (ex) {
+ console.error(
+ "ChannelEventSink collector's 'onChannelRedirect' threw an exception",
+ ex
+ );
+ }
+ }
+ callback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+}
+
+const ChannelEventSinkFactory =
+ ComponentUtils.generateSingletonFactory(ChannelEventSink);
+
+ChannelEventSinkFactory.register = function () {
+ const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ if (registrar.isCIDRegistered(SINK_CLASS_ID)) {
+ return;
+ }
+
+ registrar.registerFactory(
+ SINK_CLASS_ID,
+ SINK_CLASS_DESCRIPTION,
+ SINK_CONTRACT_ID,
+ ChannelEventSinkFactory
+ );
+
+ Services.catMan.addCategoryEntry(
+ SINK_CATEGORY_NAME,
+ SINK_CONTRACT_ID,
+ SINK_CONTRACT_ID,
+ false,
+ true
+ );
+};
+
+ChannelEventSinkFactory.unregister = function () {
+ const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ registrar.unregisterFactory(SINK_CLASS_ID, ChannelEventSinkFactory);
+
+ Services.catMan.deleteCategoryEntry(
+ SINK_CATEGORY_NAME,
+ SINK_CONTRACT_ID,
+ false
+ );
+};
+
+ChannelEventSinkFactory.getService = function () {
+ // Make sure the ChannelEventSink service is registered before accessing it
+ ChannelEventSinkFactory.register();
+
+ return Cc[SINK_CONTRACT_ID].getService(Ci.nsIChannelEventSink)
+ .wrappedJSObject;
+};
+exports.ChannelEventSinkFactory = ChannelEventSinkFactory;
diff --git a/devtools/server/actors/network-monitor/moz.build b/devtools/server/actors/network-monitor/moz.build
new file mode 100644
index 0000000000..717ccc2807
--- /dev/null
+++ b/devtools/server/actors/network-monitor/moz.build
@@ -0,0 +1,12 @@
+# -*- 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(
+ "channel-event-sink.js",
+ "network-content.js",
+ "network-event-actor.js",
+ "network-parent.js",
+)
diff --git a/devtools/server/actors/network-monitor/network-content.js b/devtools/server/actors/network-monitor/network-content.js
new file mode 100644
index 0000000000..52606a9597
--- /dev/null
+++ b/devtools/server/actors/network-monitor/network-content.js
@@ -0,0 +1,140 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ networkContentSpec,
+} = require("resource://devtools/shared/specs/network-content.js");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+loader.lazyRequireGetter(
+ this,
+ "WebConsoleUtils",
+ "resource://devtools/server/actors/webconsole/utils.js",
+ true
+);
+
+const {
+ TYPES: { NETWORK_EVENT_STACKTRACE },
+ getResourceWatcher,
+} = require("resource://devtools/server/actors/resources/index.js");
+
+/**
+ * This actor manages all network functionality runnning
+ * in the content process.
+ *
+ * @constructor
+ *
+ */
+class NetworkContentActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, networkContentSpec);
+ this.targetActor = targetActor;
+ }
+
+ get networkEventStackTraceWatcher() {
+ return getResourceWatcher(this.targetActor, NETWORK_EVENT_STACKTRACE);
+ }
+
+ /**
+ * Send an HTTP request
+ *
+ * @param {Object} request
+ * The details of the HTTP Request.
+ * @return {Number}
+ * The channel id for the request
+ */
+ async sendHTTPRequest(request) {
+ return new Promise(resolve => {
+ const { url, method, headers, body, cause } = request;
+ // Set the loadingNode and loadGroup to the target document - otherwise the
+ // request won't show up in the opened netmonitor.
+ const doc = this.targetActor.window.document;
+
+ const channel = lazy.NetUtil.newChannel({
+ uri: lazy.NetUtil.newURI(url),
+ loadingNode: doc,
+ securityFlags:
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
+ contentPolicyType:
+ lazy.NetworkUtils.stringToCauseType(cause.type) ||
+ Ci.nsIContentPolicy.TYPE_OTHER,
+ });
+
+ channel.QueryInterface(Ci.nsIHttpChannel);
+ channel.loadGroup = doc.documentLoadGroup;
+ channel.loadFlags |=
+ Ci.nsIRequest.LOAD_BYPASS_CACHE |
+ Ci.nsIRequest.INHIBIT_CACHING |
+ Ci.nsIRequest.LOAD_ANONYMOUS;
+
+ if (method == "CONNECT") {
+ throw new Error(
+ "The CONNECT method is restricted and cannot be sent by devtools"
+ );
+ }
+ channel.requestMethod = method;
+
+ if (headers) {
+ for (const { name, value } of headers) {
+ if (name.toLowerCase() == "referer") {
+ // The referer header and referrerInfo object should always match. So
+ // if we want to set the header from privileged context, we should set
+ // referrerInfo. The referrer header will get set internally.
+ channel.setNewReferrerInfo(
+ value,
+ Ci.nsIReferrerInfo.UNSAFE_URL,
+ true
+ );
+ } else {
+ channel.setRequestHeader(name, value, false);
+ }
+ }
+ }
+
+ if (body) {
+ channel.QueryInterface(Ci.nsIUploadChannel2);
+ const bodyStream = Cc[
+ "@mozilla.org/io/string-input-stream;1"
+ ].createInstance(Ci.nsIStringInputStream);
+ bodyStream.setData(body, body.length);
+ channel.explicitSetUploadStream(bodyStream, null, -1, method, false);
+ }
+
+ // Make sure the fetch has completed before sending the channel id,
+ // so that there is a higher possibilty that the request get into the
+ // redux store beforehand (but this does not gurantee that).
+ lazy.NetUtil.asyncFetch(channel, () =>
+ resolve({ channelId: channel.channelId })
+ );
+ });
+ }
+
+ /**
+ * Gets the stacktrace for the specified network resource.
+ * @param {Number} resourceId
+ * The id for the network resource
+ * @return {Object}
+ * The response packet - stack trace.
+ */
+ getStackTrace(resourceId) {
+ if (!this.networkEventStackTraceWatcher) {
+ throw new Error("Not listening for network event stacktraces");
+ }
+ const stacktrace =
+ this.networkEventStackTraceWatcher.getStackTrace(resourceId);
+ return WebConsoleUtils.removeFramesAboveDebuggerEval(stacktrace);
+ }
+}
+
+exports.NetworkContentActor = NetworkContentActor;
diff --git a/devtools/server/actors/network-monitor/network-event-actor.js b/devtools/server/actors/network-monitor/network-event-actor.js
new file mode 100644
index 0000000000..e59738dd38
--- /dev/null
+++ b/devtools/server/actors/network-monitor/network-event-actor.js
@@ -0,0 +1,684 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ networkEventSpec,
+} = require("resource://devtools/shared/specs/network-event.js");
+
+const {
+ TYPES: { NETWORK_EVENT },
+} = require("resource://devtools/server/actors/resources/index.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+const CONTENT_TYPE_REGEXP = /^content-type/i;
+
+/**
+ * Creates an actor for a network event.
+ *
+ * @constructor
+ * @param {DevToolsServerConnection} conn
+ * The connection into which this Actor will be added.
+ * @param {Object} sessionContext
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * @param {Object} options
+ * Dictionary object with the following attributes:
+ * - onNetworkEventUpdate: optional function
+ * Callback for updates for the network event
+ * - onNetworkEventDestroy: optional function
+ * Callback for the destruction of the network event
+ * @param {Object} networkEventOptions
+ * Object describing the network event or the configuration of the
+ * network observer, and which cannot be easily inferred from the raw
+ * channel.
+ * - blockingExtension: optional string
+ * id of the blocking webextension if any
+ * - blockedReason: optional number or string
+ * - discardRequestBody: boolean
+ * - discardResponseBody: boolean
+ * - fromCache: boolean
+ * - fromServiceWorker: boolean
+ * - rawHeaders: string
+ * - timestamp: number
+ * @param {nsIChannel} channel
+ * The channel related to this network event
+ */
+class NetworkEventActor extends Actor {
+ constructor(
+ conn,
+ sessionContext,
+ { onNetworkEventUpdate, onNetworkEventDestroy },
+ networkEventOptions,
+ channel
+ ) {
+ super(conn, networkEventSpec);
+
+ this._sessionContext = sessionContext;
+ this._onNetworkEventUpdate = onNetworkEventUpdate;
+ this._onNetworkEventDestroy = onNetworkEventDestroy;
+
+ // Store the channelId which will act as resource id.
+ this._channelId = channel.channelId;
+
+ this._timings = {};
+ this._serverTimings = [];
+
+ this._discardRequestBody = !!networkEventOptions.discardRequestBody;
+ this._discardResponseBody = !!networkEventOptions.discardResponseBody;
+
+ this._response = {
+ headers: [],
+ cookies: [],
+ content: {},
+ };
+
+ if (channel instanceof Ci.nsIFileChannel) {
+ this._innerWindowId = null;
+ this._isNavigationRequest = false;
+
+ this._resource = this._createResource(networkEventOptions, channel);
+ return;
+ }
+
+ // innerWindowId and isNavigationRequest are used to check if the actor
+ // should be destroyed when a window is destroyed. See network-events.js.
+ this._innerWindowId = lazy.NetworkUtils.getChannelInnerWindowId(channel);
+ this._isNavigationRequest = lazy.NetworkUtils.isNavigationRequest(channel);
+
+ // Retrieve cookies and headers from the channel
+ const { cookies, headers } =
+ lazy.NetworkUtils.fetchRequestHeadersAndCookies(channel);
+
+ this._request = {
+ cookies,
+ headers,
+ postData: {},
+ rawHeaders: networkEventOptions.rawHeaders,
+ };
+
+ this._resource = this._createResource(networkEventOptions, channel);
+ }
+
+ /**
+ * Return the network event actor as a resource, and add the actorID which is
+ * not available in the constructor yet.
+ */
+ asResource() {
+ return {
+ actor: this.actorID,
+ ...this._resource,
+ };
+ }
+
+ /**
+ * Create the resource corresponding to this actor.
+ */
+ _createResource(networkEventOptions, channel) {
+ let wsChannel;
+ let method;
+ if (channel instanceof Ci.nsIFileChannel) {
+ channel = channel.QueryInterface(Ci.nsIFileChannel);
+ channel.QueryInterface(Ci.nsIChannel);
+ wsChannel = null;
+ method = "GET";
+ } else {
+ channel = channel.QueryInterface(Ci.nsIHttpChannel);
+ wsChannel = lazy.NetworkUtils.getWebSocketChannel(channel);
+ method = channel.requestMethod;
+ }
+
+ // Use the WebSocket channel URL for websockets.
+ const url = wsChannel ? wsChannel.URI.spec : channel.URI.spec;
+
+ let browsingContextID =
+ lazy.NetworkUtils.getChannelBrowsingContextID(channel);
+
+ // Ensure that we have a browsing context ID for all requests.
+ // Only privileged requests debugged via the Browser Toolbox (sessionContext.type == "all") can be unrelated to any browsing context.
+ if (!browsingContextID && this._sessionContext.type != "all") {
+ throw new Error(`Got a request ${url} without a browsingContextID set`);
+ }
+
+ // The browsingContextID is used by the ResourceCommand on the client
+ // to find the related Target Front.
+ //
+ // For now in the browser and web extension toolboxes, requests
+ // do not relate to any specific WindowGlobalTargetActor
+ // as we are still using a unique target (ParentProcessTargetActor) for everything.
+ if (
+ this._sessionContext.type == "all" ||
+ this._sessionContext.type == "webextension"
+ ) {
+ browsingContextID = -1;
+ }
+
+ const cause = lazy.NetworkUtils.getCauseDetails(channel);
+ // Both xhr and fetch are flagged as XHR in DevTools.
+ const isXHR = cause.type == "xhr" || cause.type == "fetch";
+
+ // For websocket requests the serial is used instead of the channel id.
+ const stacktraceResourceId =
+ cause.type == "websocket" ? wsChannel.serial : channel.channelId;
+
+ // If a timestamp was provided, it is a high resolution timestamp
+ // corresponding to ACTIVITY_SUBTYPE_REQUEST_HEADER. Fallback to Date.now().
+ const timeStamp = networkEventOptions.timestamp
+ ? networkEventOptions.timestamp / 1000
+ : Date.now();
+
+ let blockedReason = networkEventOptions.blockedReason;
+
+ // Check if blockedReason was set to a falsy value, meaning the blocked did
+ // not give an explicit blocked reason.
+ if (
+ blockedReason === 0 ||
+ blockedReason === false ||
+ blockedReason === null ||
+ blockedReason === ""
+ ) {
+ blockedReason = "unknown";
+ }
+
+ const resource = {
+ resourceId: channel.channelId,
+ resourceType: NETWORK_EVENT,
+ blockedReason,
+ blockingExtension: networkEventOptions.blockingExtension,
+ browsingContextID,
+ cause,
+ // This is used specifically in the browser toolbox console to distinguish privileged
+ // resources from the parent process from those from the contet
+ chromeContext: lazy.NetworkUtils.isChannelFromSystemPrincipal(channel),
+ fromCache: networkEventOptions.fromCache,
+ fromServiceWorker: networkEventOptions.fromServiceWorker,
+ innerWindowId: this._innerWindowId,
+ isNavigationRequest: this._isNavigationRequest,
+ isFileRequest: channel instanceof Ci.nsIFileChannel,
+ isThirdPartyTrackingResource:
+ lazy.NetworkUtils.isThirdPartyTrackingResource(channel),
+ isXHR,
+ method,
+ priority: lazy.NetworkUtils.getChannelPriority(channel),
+ private: lazy.NetworkUtils.isChannelPrivate(channel),
+ referrerPolicy: lazy.NetworkUtils.getReferrerPolicy(channel),
+ stacktraceResourceId,
+ startedDateTime: new Date(timeStamp).toISOString(),
+ timeStamp,
+ timings: {},
+ url,
+ };
+
+ return resource;
+ }
+
+ /**
+ * Releases this actor from the pool.
+ */
+ destroy(conn) {
+ if (!this._channelId) {
+ return;
+ }
+
+ if (this._onNetworkEventDestroy) {
+ this._onNetworkEventDestroy(this._channelId);
+ }
+
+ this._channelId = null;
+ super.destroy(conn);
+ }
+
+ release() {
+ // Per spec, destroy is automatically going to be called after this request
+ }
+
+ getInnerWindowId() {
+ return this._innerWindowId;
+ }
+
+ isNavigationRequest() {
+ return this._isNavigationRequest;
+ }
+
+ /**
+ * The "getRequestHeaders" packet type handler.
+ *
+ * @return object
+ * The response packet - network request headers.
+ */
+ getRequestHeaders() {
+ let rawHeaders;
+ let headersSize = 0;
+ if (this._request.rawHeaders) {
+ headersSize = this._request.rawHeaders.length;
+ rawHeaders = this._createLongStringActor(this._request.rawHeaders);
+ }
+
+ return {
+ headers: this._request.headers.map(header => ({
+ name: header.name,
+ value: this._createLongStringActor(header.value),
+ })),
+ headersSize,
+ rawHeaders,
+ };
+ }
+
+ /**
+ * The "getRequestCookies" packet type handler.
+ *
+ * @return object
+ * The response packet - network request cookies.
+ */
+ getRequestCookies() {
+ return {
+ cookies: this._request.cookies.map(cookie => ({
+ name: cookie.name,
+ value: this._createLongStringActor(cookie.value),
+ })),
+ };
+ }
+
+ /**
+ * The "getRequestPostData" packet type handler.
+ *
+ * @return object
+ * The response packet - network POST data.
+ */
+ getRequestPostData() {
+ let postDataText;
+ if (this._request.postData.text) {
+ // Create a long string actor for the postData text if needed.
+ postDataText = this._createLongStringActor(this._request.postData.text);
+ }
+
+ return {
+ postData: {
+ size: this._request.postData.size,
+ text: postDataText,
+ },
+ postDataDiscarded: this._discardRequestBody,
+ };
+ }
+
+ /**
+ * The "getSecurityInfo" packet type handler.
+ *
+ * @return object
+ * The response packet - connection security information.
+ */
+ getSecurityInfo() {
+ return {
+ securityInfo: this._securityInfo,
+ };
+ }
+
+ /**
+ * The "getResponseHeaders" packet type handler.
+ *
+ * @return object
+ * The response packet - network response headers.
+ */
+ getResponseHeaders() {
+ let rawHeaders;
+ let headersSize = 0;
+ if (this._response.rawHeaders) {
+ headersSize = this._response.rawHeaders.length;
+ rawHeaders = this._createLongStringActor(this._response.rawHeaders);
+ }
+
+ return {
+ headers: this._response.headers.map(header => ({
+ name: header.name,
+ value: this._createLongStringActor(header.value),
+ })),
+ headersSize,
+ rawHeaders,
+ };
+ }
+
+ /**
+ * The "getResponseCache" packet type handler.
+ *
+ * @return object
+ * The cache packet - network cache information.
+ */
+ getResponseCache() {
+ return {
+ cache: this._response.responseCache,
+ };
+ }
+
+ /**
+ * The "getResponseCookies" packet type handler.
+ *
+ * @return object
+ * The response packet - network response cookies.
+ */
+ getResponseCookies() {
+ // As opposed to request cookies, response cookies can come with additional
+ // properties.
+ const cookieOptionalProperties = [
+ "domain",
+ "expires",
+ "httpOnly",
+ "path",
+ "samesite",
+ "secure",
+ ];
+
+ return {
+ cookies: this._response.cookies.map(cookie => {
+ const cookieResponse = {
+ name: cookie.name,
+ value: this._createLongStringActor(cookie.value),
+ };
+
+ for (const prop of cookieOptionalProperties) {
+ if (prop in cookie) {
+ cookieResponse[prop] = cookie[prop];
+ }
+ }
+ return cookieResponse;
+ }),
+ };
+ }
+
+ /**
+ * The "getResponseContent" packet type handler.
+ *
+ * @return object
+ * The response packet - network response content.
+ */
+ getResponseContent() {
+ return {
+ content: this._response.content,
+ contentDiscarded: this._discardResponseBody,
+ };
+ }
+
+ /**
+ * The "getEventTimings" packet type handler.
+ *
+ * @return object
+ * The response packet - network event timings.
+ */
+ getEventTimings() {
+ return {
+ timings: this._timings,
+ totalTime: this._totalTime,
+ offsets: this._offsets,
+ serverTimings: this._serverTimings,
+ serviceWorkerTimings: this._serviceWorkerTimings,
+ };
+ }
+
+ /** ****************************************************************
+ * Listeners for new network event data coming from NetworkMonitor.
+ ******************************************************************/
+
+ /**
+ * Add network request POST data.
+ *
+ * @param object postData
+ * The request POST data.
+ */
+ addRequestPostData(postData) {
+ // Ignore calls when this actor is already destroyed
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ this._request.postData = postData;
+ this._onEventUpdate("requestPostData", {});
+ }
+
+ /**
+ * Add the initial network response information.
+ *
+ * @param {object} options
+ * @param {nsIChannel} options.channel
+ * @param {boolean} options.fromCache
+ * @param {string} options.rawHeaders
+ * @param {string} options.proxyResponseRawHeaders
+ */
+ addResponseStart({
+ channel,
+ fromCache,
+ rawHeaders = "",
+ proxyResponseRawHeaders,
+ }) {
+ // Ignore calls when this actor is already destroyed
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ fromCache = fromCache || lazy.NetworkUtils.isFromCache(channel);
+
+ // Read response headers and cookies.
+ let responseHeaders = [];
+ let responseCookies = [];
+ if (!this._blockedReason && !(channel instanceof Ci.nsIFileChannel)) {
+ const { cookies, headers } =
+ lazy.NetworkUtils.fetchResponseHeadersAndCookies(channel);
+ responseCookies = cookies;
+ responseHeaders = headers;
+ }
+
+ // Handle response headers
+ this._response.rawHeaders = rawHeaders;
+ this._response.headers = responseHeaders;
+ this._response.cookies = responseCookies;
+
+ // Handle the rest of the response start metadata.
+ this._response.headersSize = rawHeaders ? rawHeaders.length : 0;
+
+ // Discard the response body for known response statuses.
+ if (lazy.NetworkUtils.isRedirectedChannel(channel)) {
+ this._discardResponseBody = true;
+ }
+
+ // Mime type needs to be sent on response start for identifying an sse channel.
+ const contentTypeHeader = responseHeaders.find(header =>
+ CONTENT_TYPE_REGEXP.test(header.name)
+ );
+
+ let mimeType = "";
+ if (contentTypeHeader) {
+ mimeType = contentTypeHeader.value;
+ }
+
+ let waitingTime = null;
+ if (!(channel instanceof Ci.nsIFileChannel)) {
+ const timedChannel = channel.QueryInterface(Ci.nsITimedChannel);
+ waitingTime = Math.round(
+ (timedChannel.responseStartTime - timedChannel.requestStartTime) / 1000
+ );
+ }
+
+ let proxyInfo = [];
+ if (proxyResponseRawHeaders) {
+ // The typical format for proxy raw headers is `HTTP/2 200 Connected\r\nConnection: keep-alive`
+ // The content is parsed and split into http version (HTTP/2), status(200) and status text (Connected)
+ proxyInfo = proxyResponseRawHeaders.split("\r\n")[0].split(" ");
+ }
+
+ const isFileChannel = channel instanceof Ci.nsIFileChannel;
+ this._onEventUpdate("responseStart", {
+ httpVersion: isFileChannel
+ ? null
+ : lazy.NetworkUtils.getHttpVersion(channel),
+ mimeType,
+ remoteAddress: fromCache ? "" : channel.remoteAddress,
+ remotePort: fromCache ? "" : channel.remotePort,
+ status: isFileChannel ? "200" : channel.responseStatus + "",
+ statusText: isFileChannel ? "0K" : channel.responseStatusText,
+ waitingTime,
+ isResolvedByTRR: channel.isResolvedByTRR,
+ proxyHttpVersion: proxyInfo[0],
+ proxyStatus: proxyInfo[1],
+ proxyStatusText: proxyInfo[2],
+ });
+ }
+
+ /**
+ * Add connection security information.
+ *
+ * @param object info
+ * The object containing security information.
+ */
+ addSecurityInfo(info, isRacing) {
+ // Ignore calls when this actor is already destroyed
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ this._securityInfo = info;
+
+ this._onEventUpdate("securityInfo", {
+ state: info.state,
+ isRacing,
+ });
+ }
+
+ /**
+ * Add network response content.
+ *
+ * @param object content
+ * The response content.
+ * @param object
+ * - boolean discardedResponseBody
+ * Tells if the response content was recorded or not.
+ */
+ addResponseContent(
+ content,
+ { discardResponseBody, blockedReason, blockingExtension }
+ ) {
+ // Ignore calls when this actor is already destroyed
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ this._response.content = content;
+ content.text = new LongStringActor(this.conn, content.text);
+ // bug 1462561 - Use "json" type and manually manage/marshall actors to workaround
+ // protocol.js performance issue
+ this.manage(content.text);
+ content.text = content.text.form();
+
+ this._onEventUpdate("responseContent", {
+ mimeType: content.mimeType,
+ contentSize: content.size,
+ transferredSize: content.transferredSize,
+ blockedReason,
+ blockingExtension,
+ });
+ }
+
+ addResponseCache(content) {
+ // Ignore calls when this actor is already destroyed
+ if (this.isDestroyed()) {
+ return;
+ }
+ this._response.responseCache = content.responseCache;
+ this._onEventUpdate("responseCache", {});
+ }
+
+ /**
+ * Add network event timing information.
+ *
+ * @param number total
+ * The total time of the network event.
+ * @param object timings
+ * Timing details about the network event.
+ * @param object offsets
+ */
+ addEventTimings(total, timings, offsets) {
+ // Ignore calls when this actor is already destroyed
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ this._totalTime = total;
+ this._timings = timings;
+ this._offsets = offsets;
+
+ this._onEventUpdate("eventTimings", { totalTime: total });
+ }
+
+ /**
+ * Store server timing information. They are merged together
+ * with network event timing data when they are available and
+ * notification sent to the client.
+ * See `addEventTimings` above for more information.
+ *
+ * @param object serverTimings
+ * Timing details extracted from the Server-Timing header.
+ */
+ addServerTimings(serverTimings) {
+ if (!serverTimings || this.isDestroyed()) {
+ return;
+ }
+ this._serverTimings = serverTimings;
+ }
+
+ /**
+ * Store service worker timing information. They are merged together
+ * with network event timing data when they are available and
+ * notification sent to the client.
+ * See `addEventTimnings`` above for more information.
+ *
+ * @param object serviceWorkerTimings
+ * Timing details extracted from the Timed Channel.
+ */
+ addServiceWorkerTimings(serviceWorkerTimings) {
+ if (!serviceWorkerTimings || this.isDestroyed()) {
+ return;
+ }
+ this._serviceWorkerTimings = serviceWorkerTimings;
+ }
+
+ _createLongStringActor(string) {
+ if (string?.actorID) {
+ return string;
+ }
+
+ const longStringActor = new LongStringActor(this.conn, string);
+ // bug 1462561 - Use "json" type and manually manage/marshall actors to workaround
+ // protocol.js performance issue
+ this.manage(longStringActor);
+ return longStringActor.form();
+ }
+
+ /**
+ * Sends the updated event data to the client
+ *
+ * @private
+ * @param string updateType
+ * @param object data
+ * The properties that have changed for the event
+ */
+ _onEventUpdate(updateType, data) {
+ if (this._onNetworkEventUpdate) {
+ this._onNetworkEventUpdate({
+ resourceId: this._channelId,
+ updateType,
+ ...data,
+ });
+ }
+ }
+}
+
+exports.NetworkEventActor = NetworkEventActor;
diff --git a/devtools/server/actors/network-monitor/network-parent.js b/devtools/server/actors/network-monitor/network-parent.js
new file mode 100644
index 0000000000..bc7eab1051
--- /dev/null
+++ b/devtools/server/actors/network-monitor/network-parent.js
@@ -0,0 +1,175 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ networkParentSpec,
+} = require("resource://devtools/shared/specs/network-parent.js");
+
+const {
+ TYPES: { NETWORK_EVENT },
+ getResourceWatcher,
+} = require("resource://devtools/server/actors/resources/index.js");
+
+/**
+ * This actor manages all network functionality running
+ * in the parent process.
+ *
+ * @constructor
+ *
+ */
+class NetworkParentActor extends Actor {
+ constructor(watcherActor) {
+ super(watcherActor.conn, networkParentSpec);
+ this.watcherActor = watcherActor;
+ }
+
+ // Caches the throttling data so that on clearing the
+ // current network throttling it can be reset to the previous.
+ defaultThrottleData = undefined;
+
+ isEqual(next, current) {
+ // If both objects, check all entries
+ if (current && next && next == current) {
+ return Object.entries(current).every(([k, v]) => {
+ return next[k] === v;
+ });
+ }
+ return false;
+ }
+
+ get networkEventWatcher() {
+ return getResourceWatcher(this.watcherActor, NETWORK_EVENT);
+ }
+
+ setNetworkThrottling(throttleData) {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+
+ if (throttleData !== null) {
+ throttleData = {
+ latencyMean: throttleData.latency,
+ latencyMax: throttleData.latency,
+ downloadBPSMean: throttleData.downloadThroughput,
+ downloadBPSMax: throttleData.downloadThroughput,
+ uploadBPSMean: throttleData.uploadThroughput,
+ uploadBPSMax: throttleData.uploadThroughput,
+ };
+ }
+
+ const currentThrottleData = this.networkEventWatcher.getThrottleData();
+ if (this.isEqual(throttleData, currentThrottleData)) {
+ return;
+ }
+
+ if (this.defaultThrottleData === undefined) {
+ this.defaultThrottleData = currentThrottleData;
+ }
+
+ this.networkEventWatcher.setThrottleData(throttleData);
+ }
+
+ getNetworkThrottling() {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ const throttleData = this.networkEventWatcher.getThrottleData();
+ if (!throttleData) {
+ return null;
+ }
+ return {
+ downloadThroughput: throttleData.downloadBPSMax,
+ uploadThroughput: throttleData.uploadBPSMax,
+ latency: throttleData.latencyMax,
+ };
+ }
+
+ clearNetworkThrottling() {
+ if (this.defaultThrottleData !== undefined) {
+ this.setNetworkThrottling(this.defaultThrottleData);
+ }
+ }
+
+ setSaveRequestAndResponseBodies(save) {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ this.networkEventWatcher.setSaveRequestAndResponseBodies(save);
+ }
+
+ /**
+ * Sets the urls to block.
+ *
+ * @param Array urls
+ * The response packet - stack trace.
+ */
+ setBlockedUrls(urls) {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ this.networkEventWatcher.setBlockedUrls(urls);
+ return {};
+ }
+
+ /**
+ * Returns the urls that are block
+ */
+ getBlockedUrls() {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ return this.networkEventWatcher.getBlockedUrls();
+ }
+
+ /**
+ * Blocks the requests based on the filters
+ * @param {Object} filters
+ */
+ blockRequest(filters) {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ this.networkEventWatcher.blockRequest(filters);
+ }
+
+ /**
+ * Unblocks requests based on the filters
+ * @param {Object} filters
+ */
+ unblockRequest(filters) {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ this.networkEventWatcher.unblockRequest(filters);
+ }
+
+ setPersist(enabled) {
+ // We will always call this method, even if we are still using legacy listener.
+ // Do not throw, we will always persist in that deprecated codepath.
+ if (!this.networkEventWatcher) {
+ return;
+ }
+ this.networkEventWatcher.setPersist(enabled);
+ }
+
+ override(url, path) {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ this.networkEventWatcher.override(url, path);
+ return {};
+ }
+
+ removeOverride(url) {
+ if (!this.networkEventWatcher) {
+ throw new Error("Not listening for network events");
+ }
+ this.networkEventWatcher.removeOverride(url);
+ }
+}
+
+exports.NetworkParentActor = NetworkParentActor;
diff --git a/devtools/server/actors/object.js b/devtools/server/actors/object.js
new file mode 100644
index 0000000000..9b52c4ebe7
--- /dev/null
+++ b/devtools/server/actors/object.js
@@ -0,0 +1,847 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+const { objectSpec } = require("resource://devtools/shared/specs/object.js");
+
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const { assert } = DevToolsUtils;
+
+loader.lazyRequireGetter(
+ this,
+ "PropertyIteratorActor",
+ "resource://devtools/server/actors/object/property-iterator.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "SymbolIteratorActor",
+ "resource://devtools/server/actors/object/symbol-iterator.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PrivatePropertiesIteratorActor",
+ "resource://devtools/server/actors/object/private-properties-iterator.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "previewers",
+ "resource://devtools/server/actors/object/previewers.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ ["customFormatterHeader", "customFormatterBody"],
+ "resource://devtools/server/actors/utils/custom-formatters.js",
+ true
+);
+
+// This is going to be used by findSafeGetters, where we want to avoid calling getters for
+// deprecated properties (otherwise a warning message is displayed in the console).
+// We could do something like EagerEvaluation, where we create a new Sandbox which is then
+// used to compare functions, but, we'd need to make new classes available in
+// the Sandbox, and possibly do it again when a new property gets deprecated.
+// Since this is only to be able to automatically call getters, we can simply check against
+// a list of unsafe getters that we generate from webidls.
+loader.lazyRequireGetter(
+ this,
+ "unsafeGettersNames",
+ "resource://devtools/server/actors/webconsole/webidl-unsafe-getters-names.js"
+);
+
+// ContentDOMReference requires ChromeUtils, which isn't available in worker context.
+const lazy = {};
+if (!isWorker) {
+ loader.lazyGetter(
+ lazy,
+ "ContentDOMReference",
+ () =>
+ ChromeUtils.importESModule(
+ "resource://gre/modules/ContentDOMReference.sys.mjs",
+ {
+ // ContentDOMReference needs to be retrieved from the shared global
+ // since it is a shared singleton.
+ loadInDevToolsLoader: false,
+ }
+ ).ContentDOMReference
+ );
+}
+
+const {
+ getArrayLength,
+ getPromiseState,
+ getStorageLength,
+ isArray,
+ isStorage,
+ isTypedArray,
+} = require("resource://devtools/server/actors/object/utils.js");
+
+class ObjectActor extends Actor {
+ /**
+ * Creates an actor for the specified object.
+ *
+ * @param obj Debugger.Object
+ * The debuggee object.
+ * @param Object
+ * A collection of abstract methods that are implemented by the caller.
+ * ObjectActor requires the following functions to be implemented by
+ * the caller:
+ * - createValueGrip
+ * Creates a value grip for the given object
+ * - createEnvironmentActor
+ * Creates and return an environment actor
+ * - getGripDepth
+ * An actor's grip depth getter
+ * - incrementGripDepth
+ * Increment the actor's grip depth
+ * - decrementGripDepth
+ * Decrement the actor's grip depth
+ * @param DevToolsServerConnection conn
+ */
+ constructor(
+ obj,
+ {
+ thread,
+ createValueGrip: createValueGripHook,
+ createEnvironmentActor,
+ getGripDepth,
+ incrementGripDepth,
+ decrementGripDepth,
+ customFormatterObjectTagDepth,
+ customFormatterConfigDbgObj,
+ },
+ conn
+ ) {
+ super(conn, objectSpec);
+
+ assert(
+ !obj.optimizedOut,
+ "Should not create object actors for optimized out values!"
+ );
+
+ this.obj = obj;
+ this.thread = thread;
+ this.hooks = {
+ createValueGrip: createValueGripHook,
+ createEnvironmentActor,
+ getGripDepth,
+ incrementGripDepth,
+ decrementGripDepth,
+ customFormatterObjectTagDepth,
+ customFormatterConfigDbgObj,
+ };
+ }
+
+ rawValue() {
+ return this.obj.unsafeDereference();
+ }
+
+ addWatchpoint(property, label, watchpointType) {
+ this.thread.addWatchpoint(this, { property, label, watchpointType });
+ }
+
+ removeWatchpoint(property) {
+ this.thread.removeWatchpoint(this, property);
+ }
+
+ removeWatchpoints() {
+ this.thread.removeWatchpoint(this);
+ }
+
+ /**
+ * Returns a grip for this actor for returning in a protocol message.
+ */
+ form() {
+ const g = {
+ type: "object",
+ actor: this.actorID,
+ };
+
+ const unwrapped = DevToolsUtils.unwrap(this.obj);
+ if (unwrapped === undefined) {
+ // Objects belonging to an invisible-to-debugger compartment might be proxies,
+ // so just in case they shouldn't be accessed.
+ g.class = "InvisibleToDebugger: " + this.obj.class;
+ return g;
+ }
+
+ // Only process custom formatters if the feature is enabled.
+ if (this.thread?._parent?.customFormatters) {
+ const result = customFormatterHeader(this);
+ if (result) {
+ const { formatter, ...header } = result;
+ this._customFormatterItem = formatter;
+
+ return {
+ ...g,
+ ...header,
+ };
+ }
+ }
+
+ if (unwrapped?.isProxy) {
+ // Proxy objects can run traps when accessed, so just create a preview with
+ // the target and the handler.
+ g.class = "Proxy";
+ this.hooks.incrementGripDepth();
+ previewers.Proxy[0](this, g, null);
+ this.hooks.decrementGripDepth();
+ return g;
+ }
+
+ const ownPropertyLength = this._getOwnPropertyLength();
+
+ Object.assign(g, {
+ // If the debuggee does not subsume the object's compartment, most properties won't
+ // be accessible. Cross-orgin Window and Location objects might expose some, though.
+ // Change the displayed class, but when creating the preview use the original one.
+ class: unwrapped === null ? "Restricted" : this.obj.class,
+ ownPropertyLength: Number.isFinite(ownPropertyLength)
+ ? ownPropertyLength
+ : undefined,
+ extensible: this.obj.isExtensible(),
+ frozen: this.obj.isFrozen(),
+ sealed: this.obj.isSealed(),
+ isError: this.obj.isError,
+ });
+
+ this.hooks.incrementGripDepth();
+
+ if (g.class == "Function") {
+ g.isClassConstructor = this.obj.isClassConstructor;
+ }
+
+ const raw = this.getRawObject();
+ this._populateGripPreview(g, raw);
+ this.hooks.decrementGripDepth();
+
+ if (raw && Node.isInstance(raw) && lazy.ContentDOMReference) {
+ // ContentDOMReference.get takes a DOM element and returns an object with
+ // its browsing context id, as well as a unique identifier. We are putting it in
+ // the grip here in order to be able to retrieve the node later, potentially from a
+ // different DevToolsServer running in the same process.
+ // If ContentDOMReference.get throws, we simply don't add the property to the grip.
+ try {
+ g.contentDomReference = lazy.ContentDOMReference.get(raw);
+ } catch (e) {}
+ }
+
+ return g;
+ }
+
+ customFormatterBody() {
+ return customFormatterBody(this, this._customFormatterItem);
+ }
+
+ _getOwnPropertyLength() {
+ if (isTypedArray(this.obj)) {
+ // Bug 1348761: getOwnPropertyNames is unnecessary slow on TypedArrays
+ return getArrayLength(this.obj);
+ }
+
+ if (isStorage(this.obj)) {
+ return getStorageLength(this.obj);
+ }
+
+ try {
+ return this.obj.getOwnPropertyNamesLength();
+ } catch (err) {
+ // The above can throw when the debuggee does not subsume the object's
+ // compartment, or for some WrappedNatives like Cu.Sandbox.
+ }
+
+ return null;
+ }
+
+ getRawObject() {
+ let raw = this.obj.unsafeDereference();
+
+ // If Cu is not defined, we are running on a worker thread, where xrays
+ // don't exist.
+ if (raw && Cu) {
+ raw = Cu.unwaiveXrays(raw);
+ }
+
+ if (raw && !DevToolsUtils.isSafeJSObject(raw)) {
+ raw = null;
+ }
+
+ return raw;
+ }
+
+ /**
+ * Populate the `preview` property on `grip` given its type.
+ */
+ _populateGripPreview(grip, raw) {
+ // Cache obj.class as it can be costly if this is in a hot path (e.g. logging objects
+ // within a for loop).
+ const className = this.obj.class;
+ for (const previewer of previewers[className] || previewers.Object) {
+ try {
+ const previewerResult = previewer(this, grip, raw, className);
+ if (previewerResult) {
+ return;
+ }
+ } catch (e) {
+ const msg =
+ "ObjectActor.prototype._populateGripPreview previewer function";
+ DevToolsUtils.reportException(msg, e);
+ }
+ }
+ }
+
+ /**
+ * Returns an object exposing the internal Promise state.
+ */
+ promiseState() {
+ const { state, value, reason } = getPromiseState(this.obj);
+ const promiseState = { state };
+
+ if (state == "fulfilled") {
+ promiseState.value = this.hooks.createValueGrip(value);
+ } else if (state == "rejected") {
+ promiseState.reason = this.hooks.createValueGrip(reason);
+ }
+
+ promiseState.creationTimestamp = Date.now() - this.obj.promiseLifetime;
+
+ // Only add the timeToSettle property if the Promise isn't pending.
+ if (state !== "pending") {
+ promiseState.timeToSettle = this.obj.promiseTimeToResolution;
+ }
+
+ return { promiseState };
+ }
+
+ /**
+ * Creates an actor to iterate over an object property names and values.
+ * See PropertyIteratorActor constructor for more info about options param.
+ *
+ * @param options object
+ */
+ enumProperties(options) {
+ return new PropertyIteratorActor(this, options, this.conn);
+ }
+
+ /**
+ * Creates an actor to iterate over entries of a Map/Set-like object.
+ */
+ enumEntries() {
+ return new PropertyIteratorActor(this, { enumEntries: true }, this.conn);
+ }
+
+ /**
+ * Creates an actor to iterate over an object symbols properties.
+ */
+ enumSymbols() {
+ return new SymbolIteratorActor(this, this.conn);
+ }
+
+ /**
+ * Creates an actor to iterate over an object private properties.
+ */
+ enumPrivateProperties() {
+ return new PrivatePropertiesIteratorActor(this, this.conn);
+ }
+
+ /**
+ * Handle a protocol request to provide the prototype and own properties of
+ * the object.
+ *
+ * @returns {Object} An object containing the data of this.obj, of the following form:
+ * - {Object} prototype: The descriptor of this.obj's prototype.
+ * - {Object} ownProperties: an object where the keys are the names of the
+ * this.obj's ownProperties, and the values the descriptors of
+ * the properties.
+ * - {Array} ownSymbols: An array containing all descriptors of this.obj's
+ * ownSymbols. Here we have an array, and not an object like for
+ * ownProperties, because we can have multiple symbols with the same
+ * name in this.obj, e.g. `{[Symbol()]: "a", [Symbol()]: "b"}`.
+ * - {Object} safeGetterValues: an object that maps this.obj's property names
+ * with safe getters descriptors.
+ */
+ prototypeAndProperties() {
+ let objProto = null;
+ let names = [];
+ let symbols = [];
+ if (DevToolsUtils.isSafeDebuggerObject(this.obj)) {
+ try {
+ objProto = this.obj.proto;
+ names = this.obj.getOwnPropertyNames();
+ symbols = this.obj.getOwnPropertySymbols();
+ } catch (err) {
+ // The above can throw when the debuggee does not subsume the object's
+ // compartment, or for some WrappedNatives like Cu.Sandbox.
+ }
+ }
+
+ const ownProperties = Object.create(null);
+ const ownSymbols = [];
+
+ for (const name of names) {
+ ownProperties[name] = this._propertyDescriptor(name);
+ }
+
+ for (const sym of symbols) {
+ ownSymbols.push({
+ name: sym.toString(),
+ descriptor: this._propertyDescriptor(sym),
+ });
+ }
+
+ return {
+ prototype: this.hooks.createValueGrip(objProto),
+ ownProperties,
+ ownSymbols,
+ safeGetterValues: this._findSafeGetterValues(names),
+ };
+ }
+
+ /**
+ * Find the safe getter values for the current Debugger.Object, |this.obj|.
+ *
+ * @private
+ * @param array ownProperties
+ * The array that holds the list of known ownProperties names for
+ * |this.obj|.
+ * @param number [limit=Infinity]
+ * Optional limit of getter values to find.
+ * @return object
+ * An object that maps property names to safe getter descriptors as
+ * defined by the remote debugging protocol.
+ */
+ _findSafeGetterValues(ownProperties, limit = Infinity) {
+ const safeGetterValues = Object.create(null);
+ let obj = this.obj;
+ let level = 0,
+ currentGetterValuesCount = 0;
+
+ // Do not search safe getters in unsafe objects.
+ if (!DevToolsUtils.isSafeDebuggerObject(obj)) {
+ return safeGetterValues;
+ }
+
+ // Most objects don't have any safe getters but inherit some from their
+ // prototype. Avoid calling getOwnPropertyNames on objects that may have
+ // many properties like Array, strings or js objects. That to avoid
+ // freezing firefox when doing so.
+ if (isArray(this.obj) || ["Object", "String"].includes(this.obj.class)) {
+ obj = obj.proto;
+ level++;
+ }
+
+ while (obj && DevToolsUtils.isSafeDebuggerObject(obj)) {
+ for (const name of this._findSafeGetters(obj)) {
+ // Avoid overwriting properties from prototypes closer to this.obj. Also
+ // avoid providing safeGetterValues from prototypes if property |name|
+ // is already defined as an own property.
+ if (
+ name in safeGetterValues ||
+ (obj != this.obj && ownProperties.includes(name))
+ ) {
+ continue;
+ }
+
+ // Ignore __proto__ on Object.prototye.
+ if (!obj.proto && name == "__proto__") {
+ continue;
+ }
+
+ const desc = safeGetOwnPropertyDescriptor(obj, name);
+ if (!desc?.get) {
+ // If no getter matches the name, the cache is stale and should be cleaned up.
+ obj._safeGetters = null;
+ continue;
+ }
+
+ const getterValue = this._evaluateGetter(desc.get);
+ if (getterValue === undefined) {
+ continue;
+ }
+
+ // Treat an already-rejected Promise as we would a thrown exception
+ // by not including it as a safe getter value (see Bug 1477765).
+ if (isRejectedPromise(getterValue)) {
+ // Until we have a good way to handle Promise rejections through the
+ // debugger API (Bug 1478076), call `catch` when it's safe to do so.
+ const raw = getterValue.unsafeDereference();
+ if (DevToolsUtils.isSafeJSObject(raw)) {
+ raw.catch(e => e);
+ }
+ continue;
+ }
+
+ // WebIDL attributes specified with the LenientThis extended attribute
+ // return undefined and should be ignored.
+ safeGetterValues[name] = {
+ getterValue: this.hooks.createValueGrip(getterValue),
+ getterPrototypeLevel: level,
+ enumerable: desc.enumerable,
+ writable: level == 0 ? desc.writable : true,
+ };
+
+ ++currentGetterValuesCount;
+ if (currentGetterValuesCount == limit) {
+ return safeGetterValues;
+ }
+ }
+
+ obj = obj.proto;
+ level++;
+ }
+
+ return safeGetterValues;
+ }
+
+ /**
+ * Evaluate the getter function |desc.get|.
+ * @param {Object} getter
+ */
+ _evaluateGetter(getter) {
+ const result = getter.call(this.obj);
+ if (!result || "throw" in result) {
+ return undefined;
+ }
+
+ let getterValue = undefined;
+ if ("return" in result) {
+ getterValue = result.return;
+ } else if ("yield" in result) {
+ getterValue = result.yield;
+ }
+
+ return getterValue;
+ }
+
+ /**
+ * Find the safe getters for a given Debugger.Object. Safe getters are native
+ * getters which are safe to execute.
+ *
+ * @private
+ * @param Debugger.Object object
+ * The Debugger.Object where you want to find safe getters.
+ * @return Set
+ * A Set of names of safe getters. This result is cached for each
+ * Debugger.Object.
+ */
+ _findSafeGetters(object) {
+ if (object._safeGetters) {
+ return object._safeGetters;
+ }
+
+ const getters = new Set();
+
+ if (!DevToolsUtils.isSafeDebuggerObject(object)) {
+ object._safeGetters = getters;
+ return getters;
+ }
+
+ let names = [];
+ try {
+ names = object.getOwnPropertyNames();
+ } catch (ex) {
+ // Calling getOwnPropertyNames() on some wrapped native prototypes is not
+ // allowed: "cannot modify properties of a WrappedNative". See bug 952093.
+ }
+
+ for (const name of names) {
+ let desc = null;
+ try {
+ desc = object.getOwnPropertyDescriptor(name);
+ } catch (e) {
+ // Calling getOwnPropertyDescriptor on wrapped native prototypes is not
+ // allowed (bug 560072).
+ }
+ if (!desc || desc.value !== undefined || !("get" in desc)) {
+ continue;
+ }
+
+ if (
+ DevToolsUtils.hasSafeGetter(desc) &&
+ !unsafeGettersNames.includes(name)
+ ) {
+ getters.add(name);
+ }
+ }
+
+ object._safeGetters = getters;
+ return getters;
+ }
+
+ /**
+ * Handle a protocol request to provide the prototype of the object.
+ */
+ prototype() {
+ let objProto = null;
+ if (DevToolsUtils.isSafeDebuggerObject(this.obj)) {
+ objProto = this.obj.proto;
+ }
+ return { prototype: this.hooks.createValueGrip(objProto) };
+ }
+
+ /**
+ * Handle a protocol request to provide the property descriptor of the
+ * object's specified property.
+ *
+ * @param name string
+ * The property we want the description of.
+ */
+ property(name) {
+ if (!name) {
+ return this.throwError(
+ "missingParameter",
+ "no property name was specified"
+ );
+ }
+
+ return { descriptor: this._propertyDescriptor(name) };
+ }
+
+ /**
+ * Handle a protocol request to provide the value of the object's
+ * specified property.
+ *
+ * Note: Since this will evaluate getters, it can trigger execution of
+ * content code and may cause side effects. This endpoint should only be used
+ * when you are confident that the side-effects will be safe, or the user
+ * is expecting the effects.
+ *
+ * @param {string} name
+ * The property we want the value of.
+ * @param {string|null} receiverId
+ * The actorId of the receiver to be used if the property is a getter.
+ * If null or invalid, the receiver will be the referent.
+ */
+ propertyValue(name, receiverId) {
+ if (!name) {
+ return this.throwError(
+ "missingParameter",
+ "no property name was specified"
+ );
+ }
+
+ let receiver;
+ if (receiverId) {
+ const receiverActor = this.conn.getActor(receiverId);
+ if (receiverActor) {
+ receiver = receiverActor.obj;
+ }
+ }
+
+ const value = receiver
+ ? this.obj.getProperty(name, receiver)
+ : this.obj.getProperty(name);
+
+ return { value: this._buildCompletion(value) };
+ }
+
+ /**
+ * Handle a protocol request to evaluate a function and provide the value of
+ * the result.
+ *
+ * Note: Since this will evaluate the function, it can trigger execution of
+ * content code and may cause side effects. This endpoint should only be used
+ * when you are confident that the side-effects will be safe, or the user
+ * is expecting the effects.
+ *
+ * @param {any} context
+ * The 'this' value to call the function with.
+ * @param {Array<any>} args
+ * The array of un-decoded actor objects, or primitives.
+ */
+ apply(context, args) {
+ if (!this.obj.callable) {
+ return this.throwError("notCallable", "debugee object is not callable");
+ }
+
+ const debugeeContext = this._getValueFromGrip(context);
+ const debugeeArgs = args && args.map(this._getValueFromGrip, this);
+
+ const value = this.obj.apply(debugeeContext, debugeeArgs);
+
+ return { value: this._buildCompletion(value) };
+ }
+
+ _getValueFromGrip(grip) {
+ if (typeof grip !== "object" || !grip) {
+ return grip;
+ }
+
+ if (typeof grip.actor !== "string") {
+ return this.throwError(
+ "invalidGrip",
+ "grip argument did not include actor ID"
+ );
+ }
+
+ const actor = this.conn.getActor(grip.actor);
+
+ if (!actor) {
+ return this.throwError(
+ "unknownActor",
+ "grip actor did not match a known object"
+ );
+ }
+
+ return actor.obj;
+ }
+
+ /**
+ * Converts a Debugger API completion value record into an equivalent
+ * object grip for use by the API.
+ *
+ * See https://firefox-source-docs.mozilla.org/devtools-user/debugger-api/
+ * for more specifics on the expected behavior.
+ */
+ _buildCompletion(value) {
+ let completionGrip = null;
+
+ // .apply result will be falsy if the script being executed is terminated
+ // via the "slow script" dialog.
+ if (value) {
+ completionGrip = {};
+ if ("return" in value) {
+ completionGrip.return = this.hooks.createValueGrip(value.return);
+ }
+ if ("throw" in value) {
+ completionGrip.throw = this.hooks.createValueGrip(value.throw);
+ }
+ }
+
+ return completionGrip;
+ }
+
+ /**
+ * A helper method that creates a property descriptor for the provided object,
+ * properly formatted for sending in a protocol response.
+ *
+ * @private
+ * @param string name
+ * The property that the descriptor is generated for.
+ * @param boolean [onlyEnumerable]
+ * Optional: true if you want a descriptor only for an enumerable
+ * property, false otherwise.
+ * @return object|undefined
+ * The property descriptor, or undefined if this is not an enumerable
+ * property and onlyEnumerable=true.
+ */
+ _propertyDescriptor(name, onlyEnumerable) {
+ if (!DevToolsUtils.isSafeDebuggerObject(this.obj)) {
+ return undefined;
+ }
+
+ let desc;
+ try {
+ desc = this.obj.getOwnPropertyDescriptor(name);
+ } catch (e) {
+ // Calling getOwnPropertyDescriptor on wrapped native prototypes is not
+ // allowed (bug 560072). Inform the user with a bogus, but hopefully
+ // explanatory, descriptor.
+ return {
+ configurable: false,
+ writable: false,
+ enumerable: false,
+ value: e.name,
+ };
+ }
+
+ if (isStorage(this.obj)) {
+ if (name === "length") {
+ return undefined;
+ }
+ return desc;
+ }
+
+ if (!desc || (onlyEnumerable && !desc.enumerable)) {
+ return undefined;
+ }
+
+ const retval = {
+ configurable: desc.configurable,
+ enumerable: desc.enumerable,
+ };
+ const obj = this.rawValue();
+
+ if ("value" in desc) {
+ retval.writable = desc.writable;
+ retval.value = this.hooks.createValueGrip(desc.value);
+ } else if (this.thread.getWatchpoint(obj, name.toString())) {
+ const watchpoint = this.thread.getWatchpoint(obj, name.toString());
+ retval.value = this.hooks.createValueGrip(watchpoint.desc.value);
+ retval.watchpoint = watchpoint.watchpointType;
+ } else {
+ if ("get" in desc) {
+ retval.get = this.hooks.createValueGrip(desc.get);
+ }
+
+ if ("set" in desc) {
+ retval.set = this.hooks.createValueGrip(desc.set);
+ }
+ }
+ return retval;
+ }
+
+ /**
+ * Handle a protocol request to get the target and handler internal slots of a proxy.
+ */
+ proxySlots() {
+ // There could be transparent security wrappers, unwrap to check if it's a proxy.
+ // However, retrieve proxyTarget and proxyHandler from `this.obj` to avoid exposing
+ // the unwrapped target and handler.
+ const unwrapped = DevToolsUtils.unwrap(this.obj);
+ if (!unwrapped || !unwrapped.isProxy) {
+ return this.throwError(
+ "objectNotProxy",
+ "'proxySlots' request is only valid for grips with a 'Proxy' class."
+ );
+ }
+ return {
+ proxyTarget: this.hooks.createValueGrip(this.obj.proxyTarget),
+ proxyHandler: this.hooks.createValueGrip(this.obj.proxyHandler),
+ };
+ }
+
+ /**
+ * Release the actor, when it isn't needed anymore.
+ * Protocol.js uses this release method to call the destroy method.
+ */
+ release() {
+ if (this.hooks) {
+ this.hooks.customFormatterConfigDbgObj = null;
+ }
+ this._customFormatterItem = null;
+ this.obj = null;
+ this.thread = null;
+ }
+}
+
+exports.ObjectActor = ObjectActor;
+
+function safeGetOwnPropertyDescriptor(obj, name) {
+ let desc = null;
+ try {
+ desc = obj.getOwnPropertyDescriptor(name);
+ } catch (ex) {
+ // The above can throw if the cache becomes stale.
+ }
+ return desc;
+}
+
+/**
+ * Check if the value is rejected promise
+ *
+ * @param {Object} getterValue
+ * @returns {boolean} true if the value is rejected promise, false otherwise.
+ */
+function isRejectedPromise(getterValue) {
+ return (
+ getterValue &&
+ getterValue.class == "Promise" &&
+ getterValue.promiseState == "rejected"
+ );
+}
diff --git a/devtools/server/actors/object/moz.build b/devtools/server/actors/object/moz.build
new file mode 100644
index 0000000000..28fc2307da
--- /dev/null
+++ b/devtools/server/actors/object/moz.build
@@ -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/.
+
+DevToolsModules(
+ "previewers.js",
+ "private-properties-iterator.js",
+ "property-iterator.js",
+ "symbol-iterator.js",
+ "symbol.js",
+ "utils.js",
+)
diff --git a/devtools/server/actors/object/previewers.js b/devtools/server/actors/object/previewers.js
new file mode 100644
index 0000000000..451858a826
--- /dev/null
+++ b/devtools/server/actors/object/previewers.js
@@ -0,0 +1,1142 @@
+/* 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 { DevToolsServer } = require("resource://devtools/server/devtools-server.js");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+loader.lazyRequireGetter(
+ this,
+ "ObjectUtils",
+ "resource://devtools/server/actors/object/utils.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "PropertyIterators",
+ "resource://devtools/server/actors/object/property-iterator.js"
+);
+
+// Number of items to preview in objects, arrays, maps, sets, lists,
+// collections, etc.
+const OBJECT_PREVIEW_MAX_ITEMS = 10;
+
+const ERROR_CLASSNAMES = new Set([
+ "Error",
+ "EvalError",
+ "RangeError",
+ "ReferenceError",
+ "SyntaxError",
+ "TypeError",
+ "URIError",
+ "InternalError",
+ "AggregateError",
+ "CompileError",
+ "DebuggeeWouldRun",
+ "LinkError",
+ "RuntimeError",
+ "Exception", // This related to Components.Exception()
+]);
+const ARRAY_LIKE_CLASSNAMES = new Set([
+ "DOMStringList",
+ "DOMTokenList",
+ "CSSRuleList",
+ "MediaList",
+ "StyleSheetList",
+ "NamedNodeMap",
+ "FileList",
+ "NodeList",
+]);
+const OBJECT_WITH_URL_CLASSNAMES = new Set([
+ "CSSImportRule",
+ "CSSStyleSheet",
+ "Location",
+]);
+
+/**
+ * Functions for adding information to ObjectActor grips for the purpose of
+ * having customized output. This object holds arrays mapped by
+ * Debugger.Object.prototype.class.
+ *
+ * In each array you can add functions that take three
+ * arguments:
+ * - the ObjectActor instance and its hooks to make a preview for,
+ * - the grip object being prepared for the client,
+ * - the raw JS object after calling Debugger.Object.unsafeDereference(). This
+ * argument is only provided if the object is safe for reading properties and
+ * executing methods. See DevToolsUtils.isSafeJSObject().
+ * - the object class (result of objectActor.obj.class). This is passed so we don't have
+ * to access it on each previewer, which can add some overhead.
+ *
+ * Functions must return false if they cannot provide preview
+ * information for the debugger object, or true otherwise.
+ */
+const previewers = {
+ String: [
+ function(objectActor, grip, rawObj) {
+ return wrappedPrimitivePreviewer(
+ "String",
+ String,
+ objectActor,
+ grip,
+ rawObj
+ );
+ },
+ ],
+
+ Boolean: [
+ function(objectActor, grip, rawObj) {
+ return wrappedPrimitivePreviewer(
+ "Boolean",
+ Boolean,
+ objectActor,
+ grip,
+ rawObj
+ );
+ },
+ ],
+
+ Number: [
+ function(objectActor, grip, rawObj) {
+ return wrappedPrimitivePreviewer(
+ "Number",
+ Number,
+ objectActor,
+ grip,
+ rawObj
+ );
+ },
+ ],
+
+ Symbol: [
+ function(objectActor, grip, rawObj) {
+ return wrappedPrimitivePreviewer(
+ "Symbol",
+ Symbol,
+ objectActor,
+ grip,
+ rawObj
+ );
+ },
+ ],
+
+ Function: [
+ function({ obj, hooks }, grip) {
+ if (obj.name) {
+ grip.name = obj.name;
+ }
+
+ if (obj.displayName) {
+ grip.displayName = obj.displayName.substr(0, 500);
+ }
+
+ if (obj.parameterNames) {
+ grip.parameterNames = obj.parameterNames;
+ }
+
+ // Check if the developer has added a de-facto standard displayName
+ // property for us to use.
+ let userDisplayName;
+ try {
+ userDisplayName = obj.getOwnPropertyDescriptor("displayName");
+ } catch (e) {
+ // The above can throw "permission denied" errors when the debuggee
+ // does not subsume the function's compartment.
+ }
+
+ if (
+ userDisplayName &&
+ typeof userDisplayName.value == "string" &&
+ userDisplayName.value
+ ) {
+ grip.userDisplayName = hooks.createValueGrip(userDisplayName.value);
+ }
+
+ grip.isAsync = obj.isAsyncFunction;
+ grip.isGenerator = obj.isGeneratorFunction;
+
+ if (obj.script) {
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = obj.script.format === "wasm" ? 0 : 1;
+ grip.location = {
+ url: obj.script.url,
+ line: obj.script.startLine,
+ column: obj.script.startColumn - columnBase,
+ };
+ }
+
+ return true;
+ },
+ ],
+
+ RegExp: [
+ function({ obj, hooks }, grip) {
+ const str = DevToolsUtils.callPropertyOnObject(obj, "toString");
+ if (typeof str != "string") {
+ return false;
+ }
+
+ grip.displayString = hooks.createValueGrip(str);
+ return true;
+ },
+ ],
+
+ Date: [
+ function({ obj, hooks }, grip) {
+ const time = DevToolsUtils.callPropertyOnObject(obj, "getTime");
+ if (typeof time != "number") {
+ return false;
+ }
+
+ grip.preview = {
+ timestamp: hooks.createValueGrip(time),
+ };
+ return true;
+ },
+ ],
+
+ Array: [
+ function({ obj, hooks }, grip) {
+ const length = ObjectUtils.getArrayLength(obj);
+
+ grip.preview = {
+ kind: "ArrayLike",
+ length: length,
+ };
+
+ if (hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const raw = obj.unsafeDereference();
+ const items = (grip.preview.items = []);
+
+ for (let i = 0; i < length; ++i) {
+ if (raw && !isWorker) {
+ // Array Xrays filter out various possibly-unsafe properties (like
+ // functions, and claim that the value is undefined instead. This
+ // is generally the right thing for privileged code accessing untrusted
+ // objects, but quite confusing for Object previews. So we manually
+ // override this protection by waiving Xrays on the array, and re-applying
+ // Xrays on any indexed value props that we pull off of it.
+ const desc = Object.getOwnPropertyDescriptor(Cu.waiveXrays(raw), i);
+ if (desc && !desc.get && !desc.set) {
+ let value = Cu.unwaiveXrays(desc.value);
+ value = ObjectUtils.makeDebuggeeValueIfNeeded(obj, value);
+ items.push(hooks.createValueGrip(value));
+ } else if (!desc) {
+ items.push(null);
+ } else {
+ const item = {};
+ if (desc.get) {
+ let getter = Cu.unwaiveXrays(desc.get);
+ getter = ObjectUtils.makeDebuggeeValueIfNeeded(obj, getter);
+ item.get = hooks.createValueGrip(getter);
+ }
+ if (desc.set) {
+ let setter = Cu.unwaiveXrays(desc.set);
+ setter = ObjectUtils.makeDebuggeeValueIfNeeded(obj, setter);
+ item.set = hooks.createValueGrip(setter);
+ }
+ items.push(item);
+ }
+ } else if (raw && !obj.getOwnPropertyDescriptor(i)) {
+ items.push(null);
+ } else {
+ // Workers do not have access to Cu.
+ const value = DevToolsUtils.getProperty(obj, i);
+ items.push(hooks.createValueGrip(value));
+ }
+
+ if (items.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ Set: [
+ function(objectActor, grip) {
+ const size = DevToolsUtils.getProperty(objectActor.obj, "size");
+ if (typeof size != "number") {
+ return false;
+ }
+
+ grip.preview = {
+ kind: "ArrayLike",
+ length: size,
+ };
+
+ // Avoid recursive object grips.
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const items = (grip.preview.items = []);
+ for (const item of PropertyIterators.enumSetEntries(objectActor)) {
+ items.push(item);
+ if (items.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ WeakSet: [
+ function(objectActor, grip) {
+ const enumEntries = PropertyIterators.enumWeakSetEntries(objectActor);
+
+ grip.preview = {
+ kind: "ArrayLike",
+ length: enumEntries.size,
+ };
+
+ // Avoid recursive object grips.
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const items = (grip.preview.items = []);
+ for (const item of enumEntries) {
+ items.push(item);
+ if (items.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ Map: [
+ function(objectActor, grip) {
+ const size = DevToolsUtils.getProperty(objectActor.obj, "size");
+ if (typeof size != "number") {
+ return false;
+ }
+
+ grip.preview = {
+ kind: "MapLike",
+ size: size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of PropertyIterators.enumMapEntries(objectActor)) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ WeakMap: [
+ function(objectActor, grip) {
+ const enumEntries = PropertyIterators.enumWeakMapEntries(objectActor);
+
+ grip.preview = {
+ kind: "MapLike",
+ size: enumEntries.size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of enumEntries) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ URLSearchParams: [
+ function(objectActor, grip) {
+ const enumEntries = PropertyIterators.enumURLSearchParamsEntries(objectActor);
+
+ grip.preview = {
+ kind: "MapLike",
+ size: enumEntries.size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of enumEntries) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ FormData: [
+ function(objectActor, grip) {
+ const enumEntries = PropertyIterators.enumFormDataEntries(objectActor);
+
+ grip.preview = {
+ kind: "MapLike",
+ size: enumEntries.size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of enumEntries) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ Headers: [
+ function(objectActor, grip) {
+ // Bug 1863776: Headers can't be yet previewed from workers
+ if (isWorker) {
+ return false;
+ }
+ const enumEntries = PropertyIterators.enumHeadersEntries(objectActor);
+
+ grip.preview = {
+ kind: "MapLike",
+ size: enumEntries.size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of enumEntries) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+
+ HighlightRegistry: [
+ function(objectActor, grip) {
+ const enumEntries = PropertyIterators.enumHighlightRegistryEntries(objectActor);
+
+ grip.preview = {
+ kind: "MapLike",
+ size: enumEntries.size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of enumEntries) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ MIDIInputMap: [
+ function(objectActor, grip) {
+ const enumEntries = PropertyIterators.enumMidiInputMapEntries(
+ objectActor
+ );
+
+ grip.preview = {
+ kind: "MapLike",
+ size: enumEntries.size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of enumEntries) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ MIDIOutputMap: [
+ function(objectActor, grip) {
+ const enumEntries = PropertyIterators.enumMidiOutputMapEntries(
+ objectActor
+ );
+
+ grip.preview = {
+ kind: "MapLike",
+ size: enumEntries.size,
+ };
+
+ if (objectActor.hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const entry of enumEntries) {
+ entries.push(entry);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ DOMStringMap: [
+ function({ obj, hooks }, grip, rawObj) {
+ if (!rawObj) {
+ return false;
+ }
+
+ const keys = obj.getOwnPropertyNames();
+ grip.preview = {
+ kind: "MapLike",
+ size: keys.length,
+ };
+
+ if (hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const entries = (grip.preview.entries = []);
+ for (const key of keys) {
+ const value = ObjectUtils.makeDebuggeeValueIfNeeded(obj, rawObj[key]);
+ entries.push([key, hooks.createValueGrip(value)]);
+ if (entries.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ return true;
+ },
+ ],
+
+ Promise: [
+ function({ obj, hooks }, grip, rawObj) {
+ const { state, value, reason } = ObjectUtils.getPromiseState(obj);
+ const ownProperties = Object.create(null);
+ ownProperties["<state>"] = { value: state };
+ let ownPropertiesLength = 1;
+
+ // Only expose <value> or <reason> in top-level promises, to avoid recursion.
+ // <state> is not problematic because it's a string.
+ if (hooks.getGripDepth() === 1) {
+ if (state == "fulfilled") {
+ ownProperties["<value>"] = { value: hooks.createValueGrip(value) };
+ ++ownPropertiesLength;
+ } else if (state == "rejected") {
+ ownProperties["<reason>"] = { value: hooks.createValueGrip(reason) };
+ ++ownPropertiesLength;
+ }
+ }
+
+ grip.preview = {
+ kind: "Object",
+ ownProperties,
+ ownPropertiesLength,
+ };
+
+ return true;
+ },
+ ],
+
+ Proxy: [
+ function({ obj, hooks }, grip, rawObj) {
+ // Only preview top-level proxies, avoiding recursion. Otherwise, since both the
+ // target and handler can also be proxies, we could get an exponential behavior.
+ if (hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ // The `isProxy` getter of the debuggee object only detects proxies without
+ // security wrappers. If false, the target and handler are not available.
+ const hasTargetAndHandler = obj.isProxy;
+
+ grip.preview = {
+ kind: "Object",
+ ownProperties: Object.create(null),
+ ownPropertiesLength: 2 * hasTargetAndHandler,
+ };
+
+ if (hasTargetAndHandler) {
+ Object.assign(grip.preview.ownProperties, {
+ "<target>": { value: hooks.createValueGrip(obj.proxyTarget) },
+ "<handler>": { value: hooks.createValueGrip(obj.proxyHandler) },
+ });
+ }
+
+ return true;
+ },
+ ],
+};
+
+/**
+ * Generic previewer for classes wrapping primitives, like String,
+ * Number and Boolean.
+ *
+ * @param string className
+ * Class name to expect.
+ * @param object classObj
+ * The class to expect, eg. String. The valueOf() method of the class is
+ * invoked on the given object.
+ * @param ObjectActor objectActor
+ * The object actor
+ * @param Object grip
+ * The result grip to fill in
+ * @return Booolean true if the object was handled, false otherwise
+ */
+function wrappedPrimitivePreviewer(
+ className,
+ classObj,
+ objectActor,
+ grip,
+ rawObj
+) {
+ let v = null;
+ try {
+ v = classObj.prototype.valueOf.call(rawObj);
+ } catch (ex) {
+ // valueOf() can throw if the raw JS object is "misbehaved".
+ return false;
+ }
+
+ if (v === null) {
+ return false;
+ }
+
+ const { obj, hooks } = objectActor;
+
+ const canHandle = GenericObject(objectActor, grip, rawObj, className);
+ if (!canHandle) {
+ return false;
+ }
+
+ grip.preview.wrappedValue = hooks.createValueGrip(
+ ObjectUtils.makeDebuggeeValueIfNeeded(obj, v)
+ );
+ return true;
+}
+
+/**
+ * @param {ObjectActor} objectActor
+ * @param {Object} grip: The grip built by the objectActor, for which we need to populate
+ * the `preview` property.
+ * @param {*} rawObj: The native js object
+ * @param {String} className: objectActor.obj.class
+ * @returns
+ */
+function GenericObject(objectActor, grip, rawObj, className) {
+ const { obj, hooks } = objectActor;
+ if (grip.preview || grip.displayString || hooks.getGripDepth() > 1) {
+ return false;
+ }
+
+ const preview = (grip.preview = {
+ kind: "Object",
+ ownProperties: Object.create(null),
+ });
+
+ const names = ObjectUtils.getPropNamesFromObject(obj, rawObj);
+ preview.ownPropertiesLength = names.length;
+
+ let length,
+ i = 0;
+ let specialStringBehavior = className === "String";
+ if (specialStringBehavior) {
+ length = DevToolsUtils.getProperty(obj, "length");
+ if (typeof length != "number") {
+ specialStringBehavior = false;
+ }
+ }
+
+ for (const name of names) {
+ if (specialStringBehavior && /^[0-9]+$/.test(name)) {
+ const num = parseInt(name, 10);
+ if (num.toString() === name && num >= 0 && num < length) {
+ continue;
+ }
+ }
+
+ const desc = objectActor._propertyDescriptor(name, true);
+ if (!desc) {
+ continue;
+ }
+
+ preview.ownProperties[name] = desc;
+ if (++i == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+
+ if (i === OBJECT_PREVIEW_MAX_ITEMS) {
+ return true;
+ }
+
+ const privatePropertiesSymbols = ObjectUtils.getSafePrivatePropertiesSymbols(
+ obj
+ );
+ if (privatePropertiesSymbols.length > 0) {
+ preview.privatePropertiesLength = privatePropertiesSymbols.length;
+ preview.privateProperties = [];
+
+ // Retrieve private properties, which are represented as non-enumerable Symbols
+ for (const privateProperty of privatePropertiesSymbols) {
+ if (
+ !privateProperty.description ||
+ !privateProperty.description.startsWith("#")
+ ) {
+ continue;
+ }
+ const descriptor = objectActor._propertyDescriptor(privateProperty);
+ if (!descriptor) {
+ continue;
+ }
+
+ preview.privateProperties.push(
+ Object.assign(
+ {
+ descriptor,
+ },
+ hooks.createValueGrip(privateProperty)
+ )
+ );
+
+ if (++i == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+ }
+
+ if (i === OBJECT_PREVIEW_MAX_ITEMS) {
+ return true;
+ }
+
+ const symbols = ObjectUtils.getSafeOwnPropertySymbols(obj);
+ if (symbols.length > 0) {
+ preview.ownSymbolsLength = symbols.length;
+ preview.ownSymbols = [];
+
+ for (const symbol of symbols) {
+ const descriptor = objectActor._propertyDescriptor(symbol, true);
+ if (!descriptor) {
+ continue;
+ }
+
+ preview.ownSymbols.push(
+ Object.assign(
+ {
+ descriptor,
+ },
+ hooks.createValueGrip(symbol)
+ )
+ );
+
+ if (++i == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+ }
+
+ if (i === OBJECT_PREVIEW_MAX_ITEMS) {
+ return true;
+ }
+
+ const safeGetterValues = objectActor._findSafeGetterValues(
+ Object.keys(preview.ownProperties),
+ OBJECT_PREVIEW_MAX_ITEMS - i
+ );
+ if (Object.keys(safeGetterValues).length) {
+ preview.safeGetterValues = safeGetterValues;
+ }
+
+ return true;
+}
+
+// Preview functions that do not rely on the object class.
+previewers.Object = [
+ function TypedArray({ obj, hooks }, grip) {
+ if (!ObjectUtils.isTypedArray(obj)) {
+ return false;
+ }
+
+ grip.preview = {
+ kind: "ArrayLike",
+ length: ObjectUtils.getArrayLength(obj),
+ };
+
+ if (hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const previewLength = Math.min(
+ OBJECT_PREVIEW_MAX_ITEMS,
+ grip.preview.length
+ );
+ grip.preview.items = [];
+ for (let i = 0; i < previewLength; i++) {
+ const desc = obj.getOwnPropertyDescriptor(i);
+ if (!desc) {
+ break;
+ }
+ grip.preview.items.push(desc.value);
+ }
+
+ return true;
+ },
+
+ function Error(objectActor, grip, rawObj, className) {
+ if (!ERROR_CLASSNAMES.has(className)) {
+ return false;
+ }
+
+ const { hooks, obj } = objectActor;
+
+ // The name and/or message could be getters, and even if it's unsafe, we do want
+ // to show it to the user (See Bug 1710694).
+ const name = DevToolsUtils.getProperty(obj, "name", true);
+ const msg = DevToolsUtils.getProperty(obj, "message", true);
+ const stack = DevToolsUtils.getProperty(obj, "stack");
+ const fileName = DevToolsUtils.getProperty(obj, "fileName");
+ const lineNumber = DevToolsUtils.getProperty(obj, "lineNumber");
+ const columnNumber = DevToolsUtils.getProperty(obj, "columnNumber");
+
+ grip.preview = {
+ kind: "Error",
+ name: hooks.createValueGrip(name),
+ message: hooks.createValueGrip(msg),
+ stack: hooks.createValueGrip(stack),
+ fileName: hooks.createValueGrip(fileName),
+ lineNumber: hooks.createValueGrip(lineNumber),
+ columnNumber: hooks.createValueGrip(columnNumber),
+ };
+
+ const errorHasCause = obj.getOwnPropertyNames().includes("cause");
+ if (errorHasCause) {
+ grip.preview.cause = hooks.createValueGrip(
+ DevToolsUtils.getProperty(obj, "cause", true)
+ );
+ }
+
+ return true;
+ },
+
+ function CSSMediaRule(objectActor, grip, rawObj, className) {
+ if (!rawObj || className != "CSSMediaRule" || isWorker) {
+ return false;
+ }
+ const { hooks } = objectActor;
+ grip.preview = {
+ kind: "ObjectWithText",
+ text: hooks.createValueGrip(rawObj.conditionText),
+ };
+ return true;
+ },
+
+ function CSSStyleRule(objectActor, grip, rawObj, className) {
+ if (!rawObj || className != "CSSStyleRule" || isWorker) {
+ return false;
+ }
+ const { hooks } = objectActor;
+ grip.preview = {
+ kind: "ObjectWithText",
+ text: hooks.createValueGrip(rawObj.selectorText),
+ };
+ return true;
+ },
+
+ function ObjectWithURL(objectActor, grip, rawObj, className) {
+ if (isWorker || !rawObj) {
+ return false;
+ }
+
+ const isWindow = Window.isInstance(rawObj);
+ if (!OBJECT_WITH_URL_CLASSNAMES.has(className) && !isWindow) {
+ return false;
+ }
+
+ const { hooks } = objectActor;
+
+ let url;
+ if (isWindow && rawObj.location) {
+ try {
+ url = rawObj.location.href;
+ } catch(e) {
+ // This can happen when we have a cross-process window.
+ // In such case, let's retrieve the url from the iframe.
+ // For window.top from a remote iframe, there's no way we can't retrieve the URL,
+ // so return a label that help user know what's going on.
+ url = rawObj.browsingContext?.embedderElement?.src || "Restricted";
+ }
+ } else if (rawObj.href) {
+ url = rawObj.href;
+ } else {
+ return false;
+ }
+
+ grip.preview = {
+ kind: "ObjectWithURL",
+ url: hooks.createValueGrip(url),
+ };
+
+ return true;
+ },
+
+ function ArrayLike(objectActor, grip, rawObj, className) {
+ if (
+ !rawObj ||
+ !ARRAY_LIKE_CLASSNAMES.has(className) ||
+ typeof rawObj.length != "number" ||
+ isWorker
+ ) {
+ return false;
+ }
+
+ const { obj, hooks } = objectActor;
+ grip.preview = {
+ kind: "ArrayLike",
+ length: rawObj.length,
+ };
+
+ if (hooks.getGripDepth() > 1) {
+ return true;
+ }
+
+ const items = (grip.preview.items = []);
+
+ for (
+ let i = 0;
+ i < rawObj.length && items.length < OBJECT_PREVIEW_MAX_ITEMS;
+ i++
+ ) {
+ const value = ObjectUtils.makeDebuggeeValueIfNeeded(obj, rawObj[i]);
+ items.push(hooks.createValueGrip(value));
+ }
+
+ return true;
+ },
+
+ function CSSStyleDeclaration(objectActor, grip, rawObj, className) {
+ if (
+ !rawObj ||
+ (className != "CSSStyleDeclaration" && className != "CSS2Properties") ||
+ isWorker
+ ) {
+ return false;
+ }
+
+ const { hooks } = objectActor;
+ grip.preview = {
+ kind: "MapLike",
+ size: rawObj.length,
+ };
+
+ const entries = (grip.preview.entries = []);
+
+ for (let i = 0; i < OBJECT_PREVIEW_MAX_ITEMS && i < rawObj.length; i++) {
+ const prop = rawObj[i];
+ const value = rawObj.getPropertyValue(prop);
+ entries.push([prop, hooks.createValueGrip(value)]);
+ }
+
+ return true;
+ },
+
+ function DOMNode(objectActor, grip, rawObj, className) {
+ if (
+ className == "Object" ||
+ !rawObj ||
+ !Node.isInstance(rawObj) ||
+ isWorker
+ ) {
+ return false;
+ }
+
+ const { obj, hooks } = objectActor;
+
+ const preview = (grip.preview = {
+ kind: "DOMNode",
+ nodeType: rawObj.nodeType,
+ nodeName: rawObj.nodeName,
+ isConnected: rawObj.isConnected === true,
+ });
+
+ if (rawObj.nodeType == rawObj.DOCUMENT_NODE && rawObj.location) {
+ preview.location = hooks.createValueGrip(rawObj.location.href);
+ } else if (obj.class == "DocumentFragment") {
+ preview.childNodesLength = rawObj.childNodes.length;
+
+ if (hooks.getGripDepth() < 2) {
+ preview.childNodes = [];
+ for (const node of rawObj.childNodes) {
+ const actor = hooks.createValueGrip(obj.makeDebuggeeValue(node));
+ preview.childNodes.push(actor);
+ if (preview.childNodes.length == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+ }
+ } else if (Element.isInstance(rawObj)) {
+ // For HTML elements (in an HTML document, at least), the nodeName is an
+ // uppercased version of the actual element name. Check for HTML
+ // elements, that is elements in the HTML namespace, and lowercase the
+ // nodeName in that case.
+ if (rawObj.namespaceURI == "http://www.w3.org/1999/xhtml") {
+ preview.nodeName = preview.nodeName.toLowerCase();
+ }
+
+ // Add preview for DOM element attributes.
+ preview.attributes = {};
+ preview.attributesLength = rawObj.attributes.length;
+ for (const attr of rawObj.attributes) {
+ preview.attributes[attr.nodeName] = hooks.createValueGrip(attr.value);
+ }
+ } else if (obj.class == "Attr") {
+ preview.value = hooks.createValueGrip(rawObj.value);
+ } else if (
+ obj.class == "Text" ||
+ obj.class == "CDATASection" ||
+ obj.class == "Comment"
+ ) {
+ preview.textContent = hooks.createValueGrip(rawObj.textContent);
+ }
+
+ return true;
+ },
+
+ function DOMEvent(objectActor, grip, rawObj) {
+ if (!rawObj || !Event.isInstance(rawObj) || isWorker) {
+ return false;
+ }
+
+ const { obj, hooks } = objectActor;
+ const preview = (grip.preview = {
+ kind: "DOMEvent",
+ type: rawObj.type,
+ properties: Object.create(null),
+ });
+
+ if (hooks.getGripDepth() < 2) {
+ const target = obj.makeDebuggeeValue(rawObj.target);
+ preview.target = hooks.createValueGrip(target);
+ }
+
+ if (obj.class == "KeyboardEvent") {
+ preview.eventKind = "key";
+ preview.modifiers = ObjectUtils.getModifiersForEvent(rawObj);
+ }
+
+ const props = ObjectUtils.getPropsForEvent(obj.class);
+
+ // Add event-specific properties.
+ for (const prop of props) {
+ let value = rawObj[prop];
+ if (ObjectUtils.isObjectOrFunction(value)) {
+ // Skip properties pointing to objects.
+ if (hooks.getGripDepth() > 1) {
+ continue;
+ }
+ value = obj.makeDebuggeeValue(value);
+ }
+ preview.properties[prop] = hooks.createValueGrip(value);
+ }
+
+ // Add any properties we find on the event object.
+ if (!props.length) {
+ let i = 0;
+ for (const prop in rawObj) {
+ let value = rawObj[prop];
+ if (
+ prop == "target" ||
+ prop == "type" ||
+ value === null ||
+ typeof value == "function"
+ ) {
+ continue;
+ }
+ if (value && typeof value == "object") {
+ if (hooks.getGripDepth() > 1) {
+ continue;
+ }
+ value = obj.makeDebuggeeValue(value);
+ }
+ preview.properties[prop] = hooks.createValueGrip(value);
+ if (++i == OBJECT_PREVIEW_MAX_ITEMS) {
+ break;
+ }
+ }
+ }
+
+ return true;
+ },
+
+ function DOMException(objectActor, grip, rawObj, className) {
+ if (!rawObj || className !== "DOMException" || isWorker) {
+ return false;
+ }
+
+ const { hooks } = objectActor;
+ grip.preview = {
+ kind: "DOMException",
+ name: hooks.createValueGrip(rawObj.name),
+ message: hooks.createValueGrip(rawObj.message),
+ code: hooks.createValueGrip(rawObj.code),
+ result: hooks.createValueGrip(rawObj.result),
+ filename: hooks.createValueGrip(rawObj.filename),
+ lineNumber: hooks.createValueGrip(rawObj.lineNumber),
+ columnNumber: hooks.createValueGrip(rawObj.columnNumber),
+ stack: hooks.createValueGrip(rawObj.stack),
+ };
+
+ return true;
+ },
+
+ function Object(objectActor, grip, rawObj, className) {
+ return GenericObject(objectActor, grip, rawObj, className);
+ },
+];
+
+module.exports = previewers;
diff --git a/devtools/server/actors/object/private-properties-iterator.js b/devtools/server/actors/object/private-properties-iterator.js
new file mode 100644
index 0000000000..12dc7c98e8
--- /dev/null
+++ b/devtools/server/actors/object/private-properties-iterator.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 http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ privatePropertiesIteratorSpec,
+} = require("resource://devtools/shared/specs/private-properties-iterator.js");
+
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+
+/**
+ * Creates an actor to iterate over an object's private properties.
+ *
+ * @param objectActor ObjectActor
+ * The object actor.
+ */
+class PrivatePropertiesIteratorActor extends Actor {
+ constructor(objectActor, conn) {
+ super(conn, privatePropertiesIteratorSpec);
+
+ let privateProperties = [];
+ if (DevToolsUtils.isSafeDebuggerObject(objectActor.obj)) {
+ try {
+ privateProperties = objectActor.obj.getOwnPrivateProperties();
+ } catch (err) {
+ // The above can throw when the debuggee does not subsume the object's
+ // compartment, or for some WrappedNatives like Cu.Sandbox.
+ }
+ }
+
+ this.iterator = {
+ size: privateProperties.length,
+ propertyDescription(index) {
+ // private properties are represented as Symbols on platform
+ const symbol = privateProperties[index];
+ return {
+ name: symbol.description,
+ descriptor: objectActor._propertyDescriptor(symbol),
+ };
+ },
+ };
+ }
+
+ form() {
+ return {
+ type: this.typeName,
+ actor: this.actorID,
+ count: this.iterator.size,
+ };
+ }
+
+ slice({ start, count }) {
+ const privateProperties = [];
+ for (let i = start, m = start + count; i < m; i++) {
+ privateProperties.push(this.iterator.propertyDescription(i));
+ }
+ return {
+ privateProperties,
+ };
+ }
+
+ all() {
+ return this.slice({ start: 0, count: this.iterator.size });
+ }
+ }
+
+exports.PrivatePropertiesIteratorActor = PrivatePropertiesIteratorActor;
diff --git a/devtools/server/actors/object/property-iterator.js b/devtools/server/actors/object/property-iterator.js
new file mode 100644
index 0000000000..7bd2c0a704
--- /dev/null
+++ b/devtools/server/actors/object/property-iterator.js
@@ -0,0 +1,685 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ propertyIteratorSpec,
+} = require("resource://devtools/shared/specs/property-iterator.js");
+
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+loader.lazyRequireGetter(
+ this,
+ "ObjectUtils",
+ "resource://devtools/server/actors/object/utils.js"
+);
+
+/**
+ * Creates an actor to iterate over an object's property names and values.
+ *
+ * @param objectActor ObjectActor
+ * The object actor.
+ * @param options Object
+ * A dictionary object with various boolean attributes:
+ * - enumEntries Boolean
+ * If true, enumerates the entries of a Map or Set object
+ * instead of enumerating properties.
+ * - ignoreIndexedProperties Boolean
+ * If true, filters out Array items.
+ * e.g. properties names between `0` and `object.length`.
+ * - ignoreNonIndexedProperties Boolean
+ * If true, filters out items that aren't array items
+ * e.g. properties names that are not a number between `0`
+ * and `object.length`.
+ * - sort Boolean
+ * If true, the iterator will sort the properties by name
+ * before dispatching them.
+ * - query String
+ * If non-empty, will filter the properties by names and values
+ * containing this query string. The match is not case-sensitive.
+ * Regarding value filtering it just compare to the stringification
+ * of the property value.
+ */
+class PropertyIteratorActor extends Actor {
+ constructor(objectActor, options, conn) {
+ super(conn, propertyIteratorSpec);
+ if (!DevToolsUtils.isSafeDebuggerObject(objectActor.obj)) {
+ this.iterator = {
+ size: 0,
+ propertyName: index => undefined,
+ propertyDescription: index => undefined,
+ };
+ } else if (options.enumEntries) {
+ const cls = objectActor.obj.class;
+ if (cls == "Map") {
+ this.iterator = enumMapEntries(objectActor);
+ } else if (cls == "WeakMap") {
+ this.iterator = enumWeakMapEntries(objectActor);
+ } else if (cls == "Set") {
+ this.iterator = enumSetEntries(objectActor);
+ } else if (cls == "WeakSet") {
+ this.iterator = enumWeakSetEntries(objectActor);
+ } else if (cls == "Storage") {
+ this.iterator = enumStorageEntries(objectActor);
+ } else if (cls == "URLSearchParams") {
+ this.iterator = enumURLSearchParamsEntries(objectActor);
+ } else if (cls == "Headers") {
+ this.iterator = enumHeadersEntries(objectActor);
+ } else if (cls == "HighlightRegistry") {
+ this.iterator = enumHighlightRegistryEntries(objectActor);
+ } else if (cls == "FormData") {
+ this.iterator = enumFormDataEntries(objectActor);
+ } else if (cls == "MIDIInputMap") {
+ this.iterator = enumMidiInputMapEntries(objectActor);
+ } else if (cls == "MIDIOutputMap") {
+ this.iterator = enumMidiOutputMapEntries(objectActor);
+ } else {
+ throw new Error(
+ "Unsupported class to enumerate entries from: " + cls
+ );
+ }
+ } else if (
+ ObjectUtils.isArray(objectActor.obj) &&
+ options.ignoreNonIndexedProperties &&
+ !options.query
+ ) {
+ this.iterator = enumArrayProperties(objectActor, options);
+ } else {
+ this.iterator = enumObjectProperties(objectActor, options);
+ }
+ }
+
+ form() {
+ return {
+ type: this.typeName,
+ actor: this.actorID,
+ count: this.iterator.size,
+ };
+ }
+
+ names({ indexes }) {
+ const list = [];
+ for (const idx of indexes) {
+ list.push(this.iterator.propertyName(idx));
+ }
+ return indexes;
+ }
+
+ slice({ start, count }) {
+ const ownProperties = Object.create(null);
+ for (let i = start, m = start + count; i < m; i++) {
+ const name = this.iterator.propertyName(i);
+ ownProperties[name] = this.iterator.propertyDescription(i);
+ }
+
+ return {
+ ownProperties,
+ };
+ }
+
+ all() {
+ return this.slice({ start: 0, count: this.iterator.size });
+ }
+ }
+
+function waiveXrays(obj) {
+ return isWorker ? obj : Cu.waiveXrays(obj);
+}
+
+function unwaiveXrays(obj) {
+ return isWorker ? obj : Cu.unwaiveXrays(obj);
+}
+
+/**
+ * Helper function to create a grip from a Map/Set entry
+ */
+function gripFromEntry({ obj, hooks }, entry) {
+ entry = unwaiveXrays(entry);
+ return hooks.createValueGrip(
+ ObjectUtils.makeDebuggeeValueIfNeeded(obj, entry)
+ );
+}
+
+function enumArrayProperties(objectActor, options) {
+ return {
+ size: ObjectUtils.getArrayLength(objectActor.obj),
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ return objectActor._propertyDescriptor(index);
+ },
+ };
+}
+
+function enumObjectProperties(objectActor, options) {
+ let names = [];
+ try {
+ names = objectActor.obj.getOwnPropertyNames();
+ } catch (ex) {
+ // Calling getOwnPropertyNames() on some wrapped native prototypes is not
+ // allowed: "cannot modify properties of a WrappedNative". See bug 952093.
+ }
+
+ if (options.ignoreNonIndexedProperties || options.ignoreIndexedProperties) {
+ const length = DevToolsUtils.getProperty(objectActor.obj, "length");
+ let sliceIndex;
+
+ const isLengthTrustworthy =
+ isUint32(length) &&
+ (!length || ObjectUtils.isArrayIndex(names[length - 1])) &&
+ !ObjectUtils.isArrayIndex(names[length]);
+
+ if (!isLengthTrustworthy) {
+ // The length property may not reflect what the object looks like, let's find
+ // where indexed properties end.
+
+ if (!ObjectUtils.isArrayIndex(names[0])) {
+ // If the first item is not a number, this means there is no indexed properties
+ // in this object.
+ sliceIndex = 0;
+ } else {
+ sliceIndex = names.length;
+ while (sliceIndex > 0) {
+ if (ObjectUtils.isArrayIndex(names[sliceIndex - 1])) {
+ break;
+ }
+ sliceIndex--;
+ }
+ }
+ } else {
+ sliceIndex = length;
+ }
+
+ // It appears that getOwnPropertyNames always returns indexed properties
+ // first, so we can safely slice `names` for/against indexed properties.
+ // We do such clever operation to optimize very large array inspection.
+ if (options.ignoreIndexedProperties) {
+ // Keep items after `sliceIndex` index
+ names = names.slice(sliceIndex);
+ } else if (options.ignoreNonIndexedProperties) {
+ // Keep `sliceIndex` first items
+ names.length = sliceIndex;
+ }
+ }
+
+ const safeGetterValues = objectActor._findSafeGetterValues(names);
+ const safeGetterNames = Object.keys(safeGetterValues);
+ // Merge the safe getter values into the existing properties list.
+ for (const name of safeGetterNames) {
+ if (!names.includes(name)) {
+ names.push(name);
+ }
+ }
+
+ if (options.query) {
+ let { query } = options;
+ query = query.toLowerCase();
+ names = names.filter(name => {
+ // Filter on attribute names
+ if (name.toLowerCase().includes(query)) {
+ return true;
+ }
+ // and then on attribute values
+ let desc;
+ try {
+ desc = objectActor.obj.getOwnPropertyDescriptor(name);
+ } catch (e) {
+ // Calling getOwnPropertyDescriptor on wrapped native prototypes is not
+ // allowed (bug 560072).
+ }
+ if (desc?.value && String(desc.value).includes(query)) {
+ return true;
+ }
+ return false;
+ });
+ }
+
+ if (options.sort) {
+ names.sort();
+ }
+
+ return {
+ size: names.length,
+ propertyName(index) {
+ return names[index];
+ },
+ propertyDescription(index) {
+ const name = names[index];
+ let desc = objectActor._propertyDescriptor(name);
+ if (!desc) {
+ desc = safeGetterValues[name];
+ } else if (name in safeGetterValues) {
+ // Merge the safe getter values into the existing properties list.
+ const { getterValue, getterPrototypeLevel } = safeGetterValues[name];
+ desc.getterValue = getterValue;
+ desc.getterPrototypeLevel = getterPrototypeLevel;
+ }
+ return desc;
+ },
+ };
+}
+
+function getMapEntries(obj) {
+ // Iterating over a Map via .entries goes through various intermediate
+ // objects - an Iterator object, then a 2-element Array object, then the
+ // actual values we care about. We don't have Xrays to Iterator objects,
+ // so we get Opaque wrappers for them. And even though we have Xrays to
+ // Arrays, the semantics often deny access to the entires based on the
+ // nature of the values. So we need waive Xrays for the iterator object
+ // and the tupes, and then re-apply them on the underlying values until
+ // we fix bug 1023984.
+ //
+ // Even then though, we might want to continue waiving Xrays here for the
+ // same reason we do so for Arrays above - this filtering behavior is likely
+ // to be more confusing than beneficial in the case of Object previews.
+ const raw = obj.unsafeDereference();
+ const iterator = obj.makeDebuggeeValue(
+ waiveXrays(Map.prototype.keys.call(raw))
+ );
+ return [...DevToolsUtils.makeDebuggeeIterator(iterator)].map(k => {
+ const key = waiveXrays(ObjectUtils.unwrapDebuggeeValue(k));
+ const value = Map.prototype.get.call(raw, key);
+ return [key, value];
+ });
+}
+
+function enumMapEntries(objectActor) {
+ const entries = getMapEntries(objectActor.obj);
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const [key, value] of entries) {
+ yield [key, value].map(val => gripFromEntry(objectActor, val));
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ const [key, val] = entries[index];
+ return {
+ enumerable: true,
+ value: {
+ type: "mapEntry",
+ preview: {
+ key: gripFromEntry(objectActor, key),
+ value: gripFromEntry(objectActor, val),
+ },
+ },
+ };
+ },
+ };
+}
+
+function enumStorageEntries(objectActor) {
+ // Iterating over local / sessionStorage entries goes through various
+ // intermediate objects - an Iterator object, then a 2-element Array object,
+ // then the actual values we care about. We don't have Xrays to Iterator
+ // objects, so we get Opaque wrappers for them.
+ const raw = objectActor.obj.unsafeDereference();
+ const keys = [];
+ for (let i = 0; i < raw.length; i++) {
+ keys.push(raw.key(i));
+ }
+ return {
+ [Symbol.iterator]: function*() {
+ for (const key of keys) {
+ const value = raw.getItem(key);
+ yield [key, value].map(val => gripFromEntry(objectActor, val));
+ }
+ },
+ size: keys.length,
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ const key = keys[index];
+ const val = raw.getItem(key);
+ return {
+ enumerable: true,
+ value: {
+ type: "storageEntry",
+ preview: {
+ key: gripFromEntry(objectActor, key),
+ value: gripFromEntry(objectActor, val),
+ },
+ },
+ };
+ },
+ };
+}
+
+function enumURLSearchParamsEntries(objectActor) {
+ let obj = objectActor.obj;
+ let raw = obj.unsafeDereference();
+ const entries = [...waiveXrays(URLSearchParams.prototype.entries.call(raw))];
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const [key, value] of entries) {
+ yield [key, value];
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ // UrlSearchParams entries can have the same key multiple times (e.g. `?a=1&a=2`),
+ // so let's return the index as a name to be able to display them properly in the client.
+ return index;
+ },
+ propertyDescription(index) {
+ const [key, value] = entries[index];
+
+ return {
+ enumerable: true,
+ value: {
+ type: "urlSearchParamsEntry",
+ preview: {
+ key: gripFromEntry(objectActor, key),
+ value: gripFromEntry(objectActor, value),
+ },
+ },
+ };
+ },
+ };
+}
+
+function enumFormDataEntries(objectActor) {
+ let obj = objectActor.obj;
+ let raw = obj.unsafeDereference();
+ const entries = [...waiveXrays(FormData.prototype.entries.call(raw))];
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const [key, value] of entries) {
+ yield [key, value];
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ const [key, value] = entries[index];
+
+ return {
+ enumerable: true,
+ value: {
+ type: "formDataEntry",
+ preview: {
+ key: gripFromEntry(objectActor, key),
+ value: gripFromEntry(objectActor, value),
+ },
+ },
+ };
+ },
+ };
+}
+
+function enumHeadersEntries(objectActor) {
+ let raw = objectActor.obj.unsafeDereference();
+ const entries = [...waiveXrays(Headers.prototype.entries.call(raw))];
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const [key, value] of entries) {
+ yield [key, value];
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ return entries[index][0];
+ },
+ propertyDescription(index) {
+ return {
+ enumerable: true,
+ value: gripFromEntry(objectActor, entries[index][1]),
+ };
+ },
+ };
+}
+
+function enumHighlightRegistryEntries(objectActor) {
+ const entriesFuncDbgObj = objectActor.obj.getProperty("entries").return;
+ const entriesDbgObj = entriesFuncDbgObj ? entriesFuncDbgObj.call(objectActor.obj).return : null;
+ const entries = entriesDbgObj
+ ? [...waiveXrays( entriesDbgObj.unsafeDereference())]
+ : [];
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const [key, value] of entries) {
+ yield [key, gripFromEntry(objectActor, value)];
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ const [key, value] = entries[index];
+ return {
+ enumerable: true,
+ value: {
+ type: "highlightRegistryEntry",
+ preview: {
+ key: key,
+ value: gripFromEntry(objectActor, value),
+ },
+ },
+ };
+ },
+ };
+}
+
+function enumMidiInputMapEntries(objectActor) {
+ let raw = objectActor.obj.unsafeDereference();
+ // We need to waive `raw` as we can't get the iterator from the Xray for MapLike (See Bug 1173651).
+ // We also need to waive Xrays on the result of the call to `entries` as we don't have
+ // Xrays to Iterator objects (see Bug 1023984)
+ const entries = Array.from(
+ waiveXrays(MIDIInputMap.prototype.entries.call(waiveXrays(raw)))
+ );
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const [key, value] of entries) {
+ yield [key, gripFromEntry(objectActor, value)];
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ return entries[index][0];
+ },
+ propertyDescription(index) {
+ return {
+ enumerable: true,
+ value: gripFromEntry(objectActor, entries[index][1]),
+ };
+ },
+ };
+}
+
+function enumMidiOutputMapEntries(objectActor) {
+ let raw = objectActor.obj.unsafeDereference();
+ // We need to waive `raw` as we can't get the iterator from the Xray for MapLike (See Bug 1173651).
+ // We also need to waive Xrays on the result of the call to `entries` as we don't have
+ // Xrays to Iterator objects (see Bug 1023984)
+ const entries = Array.from(
+ waiveXrays(MIDIOutputMap.prototype.entries.call(waiveXrays(raw)))
+ );
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const [key, value] of entries) {
+ yield [key, gripFromEntry(objectActor, value)];
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ return entries[index][0];
+ },
+ propertyDescription(index) {
+ return {
+ enumerable: true,
+ value: gripFromEntry(objectActor, entries[index][1]),
+ };
+ },
+ };
+}
+
+function getWeakMapEntries(obj) {
+ // We currently lack XrayWrappers for WeakMap, so when we iterate over
+ // the values, the temporary iterator objects get created in the target
+ // compartment. However, we _do_ have Xrays to Object now, so we end up
+ // Xraying those temporary objects, and filtering access to |it.value|
+ // based on whether or not it's Xrayable and/or callable, which breaks
+ // the for/of iteration.
+ //
+ // This code is designed to handle untrusted objects, so we can safely
+ // waive Xrays on the iterable, and relying on the Debugger machinery to
+ // make sure we handle the resulting objects carefully.
+ const raw = obj.unsafeDereference();
+ const keys = waiveXrays(ChromeUtils.nondeterministicGetWeakMapKeys(raw));
+
+ return keys.map(k => [k, WeakMap.prototype.get.call(raw, k)]);
+}
+
+function enumWeakMapEntries(objectActor) {
+ const entries = getWeakMapEntries(objectActor.obj);
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (let i = 0; i < entries.length; i++) {
+ yield entries[i].map(val => gripFromEntry(objectActor, val));
+ }
+ },
+ size: entries.length,
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ const [key, val] = entries[index];
+ return {
+ enumerable: true,
+ value: {
+ type: "mapEntry",
+ preview: {
+ key: gripFromEntry(objectActor, key),
+ value: gripFromEntry(objectActor, val),
+ },
+ },
+ };
+ },
+ };
+}
+
+function getSetValues(obj) {
+ // We currently lack XrayWrappers for Set, so when we iterate over
+ // the values, the temporary iterator objects get created in the target
+ // compartment. However, we _do_ have Xrays to Object now, so we end up
+ // Xraying those temporary objects, and filtering access to |it.value|
+ // based on whether or not it's Xrayable and/or callable, which breaks
+ // the for/of iteration.
+ //
+ // This code is designed to handle untrusted objects, so we can safely
+ // waive Xrays on the iterable, and relying on the Debugger machinery to
+ // make sure we handle the resulting objects carefully.
+ const raw = obj.unsafeDereference();
+ const iterator = obj.makeDebuggeeValue(
+ waiveXrays(Set.prototype.values.call(raw))
+ );
+ return [...DevToolsUtils.makeDebuggeeIterator(iterator)];
+}
+
+function enumSetEntries(objectActor) {
+ const values = getSetValues(objectActor.obj).map(v =>
+ waiveXrays(ObjectUtils.unwrapDebuggeeValue(v))
+ );
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const item of values) {
+ yield gripFromEntry(objectActor, item);
+ }
+ },
+ size: values.length,
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ const val = values[index];
+ return {
+ enumerable: true,
+ value: gripFromEntry(objectActor, val),
+ };
+ },
+ };
+}
+
+function getWeakSetEntries(obj) {
+ // We currently lack XrayWrappers for WeakSet, so when we iterate over
+ // the values, the temporary iterator objects get created in the target
+ // compartment. However, we _do_ have Xrays to Object now, so we end up
+ // Xraying those temporary objects, and filtering access to |it.value|
+ // based on whether or not it's Xrayable and/or callable, which breaks
+ // the for/of iteration.
+ //
+ // This code is designed to handle untrusted objects, so we can safely
+ // waive Xrays on the iterable, and relying on the Debugger machinery to
+ // make sure we handle the resulting objects carefully.
+ const raw = obj.unsafeDereference();
+ return waiveXrays(ChromeUtils.nondeterministicGetWeakSetKeys(raw));
+}
+
+function enumWeakSetEntries(objectActor) {
+ const keys = getWeakSetEntries(objectActor.obj);
+
+ return {
+ [Symbol.iterator]: function*() {
+ for (const item of keys) {
+ yield gripFromEntry(objectActor, item);
+ }
+ },
+ size: keys.length,
+ propertyName(index) {
+ return index;
+ },
+ propertyDescription(index) {
+ const val = keys[index];
+ return {
+ enumerable: true,
+ value: gripFromEntry(objectActor, val),
+ };
+ },
+ };
+}
+
+/**
+ * Returns true if the parameter can be stored as a 32-bit unsigned integer.
+ * If so, it will be suitable for use as the length of an array object.
+ *
+ * @param num Number
+ * The number to test.
+ * @return Boolean
+ */
+function isUint32(num) {
+ return num >>> 0 === num;
+}
+
+module.exports = {
+ PropertyIteratorActor,
+ enumMapEntries,
+ enumMidiInputMapEntries,
+ enumMidiOutputMapEntries,
+ enumSetEntries,
+ enumURLSearchParamsEntries,
+ enumFormDataEntries,
+ enumHeadersEntries,
+ enumHighlightRegistryEntries,
+ enumWeakMapEntries,
+ enumWeakSetEntries,
+};
diff --git a/devtools/server/actors/object/symbol-iterator.js b/devtools/server/actors/object/symbol-iterator.js
new file mode 100644
index 0000000000..e0b05f9cd1
--- /dev/null
+++ b/devtools/server/actors/object/symbol-iterator.js
@@ -0,0 +1,67 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { symbolIteratorSpec } = require("resource://devtools/shared/specs/symbol-iterator.js");
+
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+
+/**
+ * Creates an actor to iterate over an object's symbols.
+ *
+ * @param objectActor ObjectActor
+ * The object actor.
+ */
+class SymbolIteratorActor extends Actor {
+ constructor(objectActor, conn) {
+ super(conn, symbolIteratorSpec);
+
+ let symbols = [];
+ if (DevToolsUtils.isSafeDebuggerObject(objectActor.obj)) {
+ try {
+ symbols = objectActor.obj.getOwnPropertySymbols();
+ } catch (err) {
+ // The above can throw when the debuggee does not subsume the object's
+ // compartment, or for some WrappedNatives like Cu.Sandbox.
+ }
+ }
+
+ this.iterator = {
+ size: symbols.length,
+ symbolDescription(index) {
+ const symbol = symbols[index];
+ return {
+ name: symbol.toString(),
+ descriptor: objectActor._propertyDescriptor(symbol),
+ };
+ },
+ };
+ }
+
+ form() {
+ return {
+ type: this.typeName,
+ actor: this.actorID,
+ count: this.iterator.size,
+ };
+ }
+
+ slice({ start, count }) {
+ const ownSymbols = [];
+ for (let i = start, m = start + count; i < m; i++) {
+ ownSymbols.push(this.iterator.symbolDescription(i));
+ }
+ return {
+ ownSymbols,
+ };
+ }
+
+ all() {
+ return this.slice({ start: 0, count: this.iterator.size });
+ }
+}
+
+exports.SymbolIteratorActor = SymbolIteratorActor;
diff --git a/devtools/server/actors/object/symbol.js b/devtools/server/actors/object/symbol.js
new file mode 100644
index 0000000000..bd8bb97005
--- /dev/null
+++ b/devtools/server/actors/object/symbol.js
@@ -0,0 +1,109 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { symbolSpec } = require("resource://devtools/shared/specs/symbol.js");
+loader.lazyRequireGetter(
+ this,
+ "createValueGrip",
+ "resource://devtools/server/actors/object/utils.js",
+ true
+);
+
+/**
+ * Creates an actor for the specified symbol.
+ *
+ * @param {DevToolsServerConnection} conn: The connection to the client.
+ * @param {Symbol} symbol: The symbol we want to create an actor for.
+ */
+class SymbolActor extends Actor {
+ constructor(conn, symbol) {
+ super(conn, symbolSpec);
+ this.symbol = symbol;
+ }
+
+ rawValue() {
+ return this.symbol;
+ }
+
+ destroy() {
+ // Because symbolActors is not a weak map, we won't automatically leave
+ // it so we need to manually leave on destroy so that we don't leak
+ // memory.
+ this._releaseActor();
+ super.destroy();
+ }
+
+ /**
+ * Returns a grip for this actor for returning in a protocol message.
+ */
+ form() {
+ const form = {
+ type: this.typeName,
+ actor: this.actorID,
+ };
+ const name = getSymbolName(this.symbol);
+ if (name !== undefined) {
+ // Create a grip for the name because it might be a longString.
+ form.name = createValueGrip(name, this.getParent());
+ }
+ return form;
+ }
+
+ /**
+ * Handle a request to release this SymbolActor instance.
+ */
+ release() {
+ // TODO: also check if this.getParent() === threadActor.threadLifetimePool
+ // when the web console moves away from manually releasing pause-scoped
+ // actors.
+ this._releaseActor();
+ this.destroy();
+ return {};
+ }
+
+ _releaseActor() {
+ const parent = this.getParent();
+ if (parent && parent.symbolActors) {
+ delete parent.symbolActors[this.symbol];
+ }
+ }
+}
+
+const symbolProtoToString = Symbol.prototype.toString;
+
+function getSymbolName(symbol) {
+ const name = symbolProtoToString.call(symbol).slice("Symbol(".length, -1);
+ return name || undefined;
+}
+
+/**
+ * Create a grip for the given symbol.
+ *
+ * @param sym Symbol
+ * The symbol we are creating a grip for.
+ * @param pool Pool
+ * The actor pool where the new actor will be added.
+ */
+function symbolGrip(sym, pool) {
+ if (!pool.symbolActors) {
+ pool.symbolActors = Object.create(null);
+ }
+
+ if (sym in pool.symbolActors) {
+ return pool.symbolActors[sym].form();
+ }
+
+ const actor = new SymbolActor(pool.conn, sym);
+ pool.manage(actor);
+ pool.symbolActors[sym] = actor;
+ return actor.form();
+}
+
+module.exports = {
+ SymbolActor,
+ symbolGrip,
+};
diff --git a/devtools/server/actors/object/utils.js b/devtools/server/actors/object/utils.js
new file mode 100644
index 0000000000..d397b4badf
--- /dev/null
+++ b/devtools/server/actors/object/utils.js
@@ -0,0 +1,615 @@
+/* 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 {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const { assert } = DevToolsUtils;
+
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "resource://devtools/server/actors/string.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "symbolGrip",
+ "resource://devtools/server/actors/object/symbol.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "ObjectActor",
+ "resource://devtools/server/actors/object.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "EnvironmentActor",
+ "resource://devtools/server/actors/environment.js",
+ true
+);
+
+/**
+ * Get thisDebugger.Object referent's `promiseState`.
+ *
+ * @returns Object
+ * An object of one of the following forms:
+ * - { state: "pending" }
+ * - { state: "fulfilled", value }
+ * - { state: "rejected", reason }
+ */
+function getPromiseState(obj) {
+ if (obj.class != "Promise") {
+ throw new Error(
+ "Can't call `getPromiseState` on `Debugger.Object`s that don't " +
+ "refer to Promise objects."
+ );
+ }
+
+ const state = { state: obj.promiseState };
+ if (state.state === "fulfilled") {
+ state.value = obj.promiseValue;
+ } else if (state.state === "rejected") {
+ state.reason = obj.promiseReason;
+ }
+ return state;
+}
+
+/**
+ * Returns true if value is an object or function.
+ *
+ * @param value
+ * @returns {boolean}
+ */
+
+function isObjectOrFunction(value) {
+ // Handle null, whose typeof is object
+ if (!value) {
+ return false;
+ }
+
+ const type = typeof value;
+ return type == "object" || type == "function";
+}
+
+/**
+ * Make a debuggee value for the given object, if needed. Primitive values
+ * are left the same.
+ *
+ * Use case: you have a raw JS object (after unsafe dereference) and you want to
+ * send it to the client. In that case you need to use an ObjectActor which
+ * requires a debuggee value. The Debugger.Object.prototype.makeDebuggeeValue()
+ * method works only for JS objects and functions.
+ *
+ * @param Debugger.Object obj
+ * @param any value
+ * @return object
+ */
+function makeDebuggeeValueIfNeeded(obj, value) {
+ if (isObjectOrFunction(value)) {
+ return obj.makeDebuggeeValue(value);
+ }
+ return value;
+}
+
+/**
+ * Convert a debuggee value into the underlying raw object, if needed.
+ */
+function unwrapDebuggeeValue(value) {
+ if (value && typeof value == "object") {
+ return value.unsafeDereference();
+ }
+ return value;
+}
+
+/**
+ * Create a grip for the given debuggee value. If the value is an object or a long string,
+ * it will create an actor and add it to the pool
+ * @param {any} value: The debuggee value.
+ * @param {Pool} pool: The pool where the created actor will be added.
+ * @param {Function} makeObjectGrip: Function that will be called to create the grip for
+ * non-primitive values.
+ */
+function createValueGrip(value, pool, makeObjectGrip) {
+ switch (typeof value) {
+ case "boolean":
+ return value;
+
+ case "string":
+ return createStringGrip(pool, value);
+
+ case "number":
+ if (value === Infinity) {
+ return { type: "Infinity" };
+ } else if (value === -Infinity) {
+ return { type: "-Infinity" };
+ } else if (Number.isNaN(value)) {
+ return { type: "NaN" };
+ } else if (!value && 1 / value === -Infinity) {
+ return { type: "-0" };
+ }
+ return value;
+
+ case "bigint":
+ return {
+ type: "BigInt",
+ text: value.toString(),
+ };
+
+ // TODO(bug 1772157)
+ // Record/tuple grips aren't fully implemented yet.
+ case "record":
+ return {
+ class: "Record",
+ };
+ case "tuple":
+ return {
+ class: "Tuple",
+ };
+ case "undefined":
+ return { type: "undefined" };
+
+ case "object":
+ if (value === null) {
+ return { type: "null" };
+ } else if (
+ value.optimizedOut ||
+ value.uninitialized ||
+ value.missingArguments
+ ) {
+ // The slot is optimized out, an uninitialized binding, or
+ // arguments on a dead scope
+ return {
+ type: "null",
+ optimizedOut: value.optimizedOut,
+ uninitialized: value.uninitialized,
+ missingArguments: value.missingArguments,
+ };
+ }
+ return makeObjectGrip(value, pool);
+
+ case "symbol":
+ return symbolGrip(value, pool);
+
+ default:
+ assert(false, "Failed to provide a grip for: " + value);
+ return null;
+ }
+}
+
+/**
+ * of passing the value directly over the protocol.
+ *
+ * @param str String
+ * The string we are checking the length of.
+ */
+function stringIsLong(str) {
+ return str.length >= DevToolsServer.LONG_STRING_LENGTH;
+}
+
+const TYPED_ARRAY_CLASSES = [
+ "Uint8Array",
+ "Uint8ClampedArray",
+ "Uint16Array",
+ "Uint32Array",
+ "Int8Array",
+ "Int16Array",
+ "Int32Array",
+ "Float32Array",
+ "Float64Array",
+ "BigInt64Array",
+ "BigUint64Array",
+];
+
+/**
+ * Returns true if a debuggee object is a typed array.
+ *
+ * @param obj Debugger.Object
+ * The debuggee object to test.
+ * @return Boolean
+ */
+function isTypedArray(object) {
+ return TYPED_ARRAY_CLASSES.includes(object.class);
+}
+
+/**
+ * Returns true if a debuggee object is an array, including a typed array.
+ *
+ * @param obj Debugger.Object
+ * The debuggee object to test.
+ * @return Boolean
+ */
+function isArray(object) {
+ return isTypedArray(object) || object.class === "Array";
+}
+
+/**
+ * Returns the length of an array (or typed array).
+ *
+ * @param object Debugger.Object
+ * The debuggee object of the array.
+ * @return Number
+ * @throws if the object is not an array.
+ */
+function getArrayLength(object) {
+ if (!isArray(object)) {
+ throw new Error("Expected an array, got a " + object.class);
+ }
+
+ // Real arrays have a reliable `length` own property.
+ if (object.class === "Array") {
+ return DevToolsUtils.getProperty(object, "length");
+ }
+
+ // For typed arrays, `DevToolsUtils.getProperty` is not reliable because the `length`
+ // getter could be shadowed by an own property, and `getOwnPropertyNames` is
+ // unnecessarily slow. Obtain the `length` getter safely and call it manually.
+ const typedProto = Object.getPrototypeOf(Uint8Array.prototype);
+ const getter = Object.getOwnPropertyDescriptor(typedProto, "length").get;
+ return getter.call(object.unsafeDereference());
+}
+
+/**
+ * Returns true if the parameter is suitable to be an array index.
+ *
+ * @param str String
+ * @return Boolean
+ */
+function isArrayIndex(str) {
+ // Transform the parameter to a 32-bit unsigned integer.
+ const num = str >>> 0;
+ // Check that the parameter is a canonical Uint32 index.
+ return (
+ num + "" === str &&
+ // Array indices cannot attain the maximum Uint32 value.
+ num != -1 >>> 0
+ );
+}
+
+/**
+ * Returns true if a debuggee object is a local or sessionStorage object.
+ *
+ * @param object Debugger.Object
+ * The debuggee object to test.
+ * @return Boolean
+ */
+function isStorage(object) {
+ return object.class === "Storage";
+}
+
+/**
+ * Returns the length of a local or sessionStorage object.
+ *
+ * @param object Debugger.Object
+ * The debuggee object of the array.
+ * @return Number
+ * @throws if the object is not a local or sessionStorage object.
+ */
+function getStorageLength(object) {
+ if (!isStorage(object)) {
+ throw new Error("Expected a storage object, got a " + object.class);
+ }
+ return DevToolsUtils.getProperty(object, "length");
+}
+
+/**
+ * Returns an array of properties based on event class name.
+ *
+ * @param className
+ * @returns {Array}
+ */
+function getPropsForEvent(className) {
+ const positionProps = ["buttons", "clientX", "clientY", "layerX", "layerY"];
+ const eventToPropsMap = {
+ MouseEvent: positionProps,
+ DragEvent: positionProps,
+ PointerEvent: positionProps,
+ SimpleGestureEvent: positionProps,
+ WheelEvent: positionProps,
+ KeyboardEvent: ["key", "charCode", "keyCode"],
+ TransitionEvent: ["propertyName", "pseudoElement"],
+ AnimationEvent: ["animationName", "pseudoElement"],
+ ClipboardEvent: ["clipboardData"],
+ };
+
+ if (className in eventToPropsMap) {
+ return eventToPropsMap[className];
+ }
+
+ return [];
+}
+
+/**
+ * Returns an array of of all properties of an object
+ *
+ * @param obj
+ * @param rawObj
+ * @returns {Array|Iterable} If rawObj is localStorage/sessionStorage, we don't return an
+ * array but an iterable object (with the proper `length` property) to avoid
+ * performance issues.
+ */
+function getPropNamesFromObject(obj, rawObj) {
+ try {
+ if (isStorage(obj)) {
+ // local and session storage cannot be iterated over using
+ // Object.getOwnPropertyNames() because it skips keys that are duplicated
+ // on the prototype e.g. "key", "getKeys" so we need to gather the real
+ // keys using the storage.key() function.
+ // As the method is pretty slow, we return an iterator here, so we don't consume
+ // more than we need, especially since we're calling this from previewers in which
+ // we only need the first 10 entries for the preview (See Bug 1741804).
+
+ // Still return the proper number of entries.
+ const length = rawObj.length;
+ const iterable = { length };
+ iterable[Symbol.iterator] = function*() {
+ for (let j = 0; j < length; j++) {
+ yield rawObj.key(j);
+ }
+ };
+ return iterable;
+ }
+
+ return obj.getOwnPropertyNames();
+ } catch (ex) {
+ // Calling getOwnPropertyNames() on some wrapped native prototypes is not
+ // allowed: "cannot modify properties of a WrappedNative". See bug 952093.
+ }
+
+ return [];
+}
+
+/**
+ * Returns an array of private properties of an object
+ *
+ * @param obj
+ * @returns {Array}
+ */
+function getSafePrivatePropertiesSymbols(obj) {
+ try {
+ return obj.getOwnPrivateProperties();
+ } catch (ex) {
+ return [];
+ }
+}
+
+/**
+ * Returns an array of all symbol properties of an object
+ *
+ * @param obj
+ * @returns {Array}
+ */
+function getSafeOwnPropertySymbols(obj) {
+ try {
+ return obj.getOwnPropertySymbols();
+ } catch (ex) {
+ return [];
+ }
+}
+
+/**
+ * Returns an array modifiers based on keys
+ *
+ * @param rawObj
+ * @returns {Array}
+ */
+function getModifiersForEvent(rawObj) {
+ const modifiers = [];
+ const keysToModifiersMap = {
+ altKey: "Alt",
+ ctrlKey: "Control",
+ metaKey: "Meta",
+ shiftKey: "Shift",
+ };
+
+ for (const key in keysToModifiersMap) {
+ if (keysToModifiersMap.hasOwnProperty(key) && rawObj[key]) {
+ modifiers.push(keysToModifiersMap[key]);
+ }
+ }
+
+ return modifiers;
+}
+
+/**
+ * Make a debuggee value for the given value.
+ *
+ * @param TargetActor targetActor
+ * The Target Actor from which this object originates.
+ * @param mixed value
+ * The value you want to get a debuggee value for.
+ * @return object
+ * Debuggee value for |value|.
+ */
+function makeDebuggeeValue(targetActor, value) {
+ // Primitive types are debuggee values and Debugger.Object.makeDebuggeeValue
+ // would return them unchanged. So avoid the expense of:
+ // getGlobalForObject+makeGlobalObjectReference+makeDebugeeValue for them.
+ //
+ // It is actually easier to identify non primitive which can only be object or function.
+ if (!isObjectOrFunction(value)) {
+ return value;
+ }
+
+ // `value` may come from various globals.
+ // And Debugger.Object.makeDebuggeeValue only works for objects
+ // related to the same global. So fetch the global first,
+ // in order to instantiate a Debugger.Object for it.
+ //
+ // In the worker thread, we don't have access to Cu,
+ // but at the same time, there is only one global, the worker one.
+ const valueGlobal = isWorker ? targetActor.workerGlobal : Cu.getGlobalForObject(value);
+ let dbgGlobal;
+ try {
+ dbgGlobal = targetActor.dbg.makeGlobalObjectReference(
+ valueGlobal
+ );
+ } catch(e) {
+ // makeGlobalObjectReference will throw if the global is invisible to Debugger,
+ // in this case instantiate a Debugger.Object for the top level global
+ // of the target. Even if value will come from another global, it will "work",
+ // but the Debugger.Object created via dbgGlobal.makeDebuggeeValue will throw
+ // on most methods as the object will also be invisible to Debuggee...
+ if (e.message.includes("object in compartment marked as invisible to Debugger")) {
+ dbgGlobal = targetActor.dbg.makeGlobalObjectReference(
+ targetActor.window
+ );
+
+ } else {
+ throw e;
+ }
+ }
+
+ return dbgGlobal.makeDebuggeeValue(value);
+}
+
+/**
+ * Create a grip for the given string.
+ *
+ * @param TargetActor targetActor
+ * The Target Actor from which this object originates.
+ */
+function createStringGrip(targetActor, string) {
+ if (string && stringIsLong(string)) {
+ const actor = new LongStringActor(targetActor.conn, string);
+ targetActor.manage(actor);
+ return actor.form();
+ }
+ return string;
+}
+
+/**
+ * Create a grip for the given value.
+ *
+ * @param TargetActor targetActor
+ * The Target Actor from which this object originates.
+ * @param mixed value
+ * The value you want to get a debuggee value for.
+ * @param Number depth
+ * Depth of the object compared to the top level object,
+ * when we are inspecting nested attributes.
+ * @param Object [objectActorAttributes]
+ * An optional object whose properties will be assigned to the ObjectActor if one
+ * is created.
+ * @return object
+ */
+function createValueGripForTarget(
+ targetActor,
+ value,
+ depth = 0,
+ objectActorAttributes = {}
+) {
+ const makeObjectGrip = (objectActorValue, pool) =>
+ createObjectGrip(
+ targetActor,
+ depth,
+ objectActorValue,
+ pool,
+ objectActorAttributes
+ );
+ return createValueGrip(value, targetActor, makeObjectGrip);
+}
+
+/**
+ * Create and return an environment actor that corresponds to the provided
+ * Debugger.Environment. This is a straightforward clone of the ThreadActor's
+ * method except that it stores the environment actor in the web console
+ * actor's pool.
+ *
+ * @param Debugger.Environment environment
+ * The lexical environment we want to extract.
+ * @param TargetActor targetActor
+ * The Target Actor to use as parent actor.
+ * @return The EnvironmentActor for |environment| or |undefined| for host
+ * functions or functions scoped to a non-debuggee global.
+ */
+function createEnvironmentActor(environment, targetActor) {
+ if (!environment) {
+ return undefined;
+ }
+
+ if (environment.actor) {
+ return environment.actor;
+ }
+
+ const actor = new EnvironmentActor(environment, targetActor);
+ targetActor.manage(actor);
+ environment.actor = actor;
+
+ return actor;
+}
+
+/**
+ * Create a grip for the given object.
+ *
+ * @param TargetActor targetActor
+ * The Target Actor from which this object originates.
+ * @param Number depth
+ * Depth of the object compared to the top level object,
+ * when we are inspecting nested attributes.
+ * @param object object
+ * The object you want.
+ * @param object pool
+ * A Pool where the new actor instance is added.
+ * @param object [objectActorAttributes]
+ * An optional object whose properties will be assigned to the ObjectActor being created.
+ * @param object
+ * The object grip.
+ */
+function createObjectGrip(
+ targetActor,
+ depth,
+ object,
+ pool,
+ objectActorAttributes = {}
+) {
+ let gripDepth = depth;
+ const actor = new ObjectActor(
+ object,
+ {
+ ...objectActorAttributes,
+ thread: targetActor.threadActor,
+ getGripDepth: () => gripDepth,
+ incrementGripDepth: () => gripDepth++,
+ decrementGripDepth: () => gripDepth--,
+ createValueGrip: v => createValueGripForTarget(targetActor, v, gripDepth),
+ createEnvironmentActor: env => createEnvironmentActor(env, targetActor),
+ },
+ targetActor.conn
+ );
+ pool.manage(actor);
+
+ return actor.form();
+}
+
+module.exports = {
+ getPromiseState,
+ makeDebuggeeValueIfNeeded,
+ unwrapDebuggeeValue,
+ createValueGrip,
+ stringIsLong,
+ isTypedArray,
+ isArray,
+ isStorage,
+ getArrayLength,
+ getStorageLength,
+ isArrayIndex,
+ getPropsForEvent,
+ getPropNamesFromObject,
+ getSafeOwnPropertySymbols,
+ getSafePrivatePropertiesSymbols,
+ getModifiersForEvent,
+ isObjectOrFunction,
+ createStringGrip,
+ makeDebuggeeValue,
+ createValueGripForTarget,
+};
diff --git a/devtools/server/actors/objects-manager.js b/devtools/server/actors/objects-manager.js
new file mode 100644
index 0000000000..529b33b246
--- /dev/null
+++ b/devtools/server/actors/objects-manager.js
@@ -0,0 +1,39 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ objectsManagerSpec,
+} = require("resource://devtools/shared/specs/objects-manager.js");
+
+/**
+ * This actor is a singleton per Target which allows interacting with JS Object
+ * inspected by DevTools. Typically from the Console or Debugger.
+ */
+class ObjectsManagerActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, objectsManagerSpec);
+ }
+
+ /**
+ * Release Actors by bulk by specifying their actor IDs.
+ * (Passing the whole Front [i.e. Actor's form] would be more expensive than passing only their IDs)
+ *
+ * @param {Array<string>} actorIDs
+ * List of all actor's IDs to release.
+ */
+ releaseObjects(actorIDs) {
+ for (const actorID of actorIDs) {
+ const actor = this.conn.getActor(actorID);
+ // Note that release will also typically call Actor's destroy and unregister the actor from its Pool
+ if (actor) {
+ actor.release();
+ }
+ }
+ }
+}
+
+exports.ObjectsManagerActor = ObjectsManagerActor;
diff --git a/devtools/server/actors/page-style.js b/devtools/server/actors/page-style.js
new file mode 100644
index 0000000000..cfaa35ed46
--- /dev/null
+++ b/devtools/server/actors/page-style.js
@@ -0,0 +1,1297 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ pageStyleSpec,
+} = require("resource://devtools/shared/specs/page-style.js");
+
+const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+const TrackChangeEmitter = require("resource://devtools/server/actors/utils/track-change-emitter.js");
+
+const {
+ style: { ELEMENT_STYLE },
+} = require("resource://devtools/shared/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "StyleRuleActor",
+ "resource://devtools/server/actors/style-rule.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getFontPreviewData",
+ "resource://devtools/server/actors/utils/style-utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "SharedCssLogic",
+ "resource://devtools/shared/inspector/css-logic.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "getDefinedGeometryProperties",
+ "resource://devtools/server/actors/highlighters/geometry-editor.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "UPDATE_GENERAL",
+ "resource://devtools/server/actors/utils/stylesheets-manager.js",
+ true
+);
+
+loader.lazyGetter(this, "PSEUDO_ELEMENTS", () => {
+ return InspectorUtils.getCSSPseudoElementNames();
+});
+loader.lazyGetter(this, "FONT_VARIATIONS_ENABLED", () => {
+ return Services.prefs.getBoolPref("layout.css.font-variations.enabled");
+});
+
+const NORMAL_FONT_WEIGHT = 400;
+const BOLD_FONT_WEIGHT = 700;
+
+/**
+ * The PageStyle actor lets the client look at the styles on a page, as
+ * they are applied to a given node.
+ */
+class PageStyleActor extends Actor {
+ /**
+ * Create a PageStyleActor.
+ *
+ * @param inspector
+ * The InspectorActor that owns this PageStyleActor.
+ *
+ * @constructor
+ */
+ constructor(inspector) {
+ super(inspector.conn, pageStyleSpec);
+ this.inspector = inspector;
+ if (!this.inspector.walker) {
+ throw Error(
+ "The inspector's WalkerActor must be created before " +
+ "creating a PageStyleActor."
+ );
+ }
+ this.walker = inspector.walker;
+ this.cssLogic = new CssLogic();
+
+ // Stores the association of DOM objects -> actors
+ this.refMap = new Map();
+
+ // Latest node queried for its applied styles.
+ this.selectedElement = null;
+
+ // Maps document elements to style elements, used to add new rules.
+ this.styleElements = new WeakMap();
+
+ this.onFrameUnload = this.onFrameUnload.bind(this);
+
+ this.inspector.targetActor.on("will-navigate", this.onFrameUnload);
+
+ this._observedRules = [];
+ this._styleApplied = this._styleApplied.bind(this);
+
+ this.styleSheetsManager =
+ this.inspector.targetActor.getStyleSheetsManager();
+
+ this._onStylesheetUpdated = this._onStylesheetUpdated.bind(this);
+ this.styleSheetsManager.on("stylesheet-updated", this._onStylesheetUpdated);
+ }
+
+ destroy() {
+ if (!this.walker) {
+ return;
+ }
+ super.destroy();
+ this.inspector.targetActor.off("will-navigate", this.onFrameUnload);
+ this.inspector = null;
+ this.walker = null;
+ this.refMap = null;
+ this.selectedElement = null;
+ this.cssLogic = null;
+ this.styleElements = null;
+
+ this._observedRules = [];
+ }
+
+ get ownerWindow() {
+ return this.inspector.targetActor.window;
+ }
+
+ form() {
+ // We need to use CSS from the inspected window in order to use CSS.supports() and
+ // detect the right platform features from there.
+ const CSS = this.inspector.targetActor.window.CSS;
+
+ return {
+ actor: this.actorID,
+ traits: {
+ // Whether the page supports values of font-stretch from CSS Fonts Level 4.
+ fontStretchLevel4: CSS.supports("font-stretch: 100%"),
+ // Whether the page supports values of font-style from CSS Fonts Level 4.
+ fontStyleLevel4: CSS.supports("font-style: oblique 20deg"),
+ // Whether getAllUsedFontFaces/getUsedFontFaces accepts the includeVariations
+ // argument.
+ fontVariations: FONT_VARIATIONS_ENABLED,
+ // Whether the page supports values of font-weight from CSS Fonts Level 4.
+ // font-weight at CSS Fonts Level 4 accepts values in increments of 1 rather
+ // than 100. However, CSS.supports() returns false positives, so we guard with the
+ // expected support of font-stretch at CSS Fonts Level 4.
+ fontWeightLevel4:
+ CSS.supports("font-weight: 1") && CSS.supports("font-stretch: 100%"),
+ },
+ };
+ }
+
+ /**
+ * Called when a style sheet is updated.
+ */
+ _styleApplied(kind) {
+ // No matter what kind of update is done, we need to invalidate
+ // the keyframe cache.
+ this.cssLogic.reset();
+ if (kind === UPDATE_GENERAL) {
+ this.emit("stylesheet-updated");
+ }
+ }
+
+ /**
+ * Return or create a StyleRuleActor for the given item.
+ * @param item Either a CSSStyleRule or a DOM element.
+ * @param userAdded Optional boolean to distinguish rules added by the user.
+ */
+ _styleRef(item, userAdded = false) {
+ if (this.refMap.has(item)) {
+ return this.refMap.get(item);
+ }
+ const actor = new StyleRuleActor(this, item, userAdded);
+ this.manage(actor);
+ this.refMap.set(item, actor);
+
+ return actor;
+ }
+
+ /**
+ * Update the association between a StyleRuleActor and its
+ * corresponding item. This is used when a StyleRuleActor updates
+ * as style sheet and starts using a new rule.
+ *
+ * @param oldItem The old association; either a CSSStyleRule or a
+ * DOM element.
+ * @param item Either a CSSStyleRule or a DOM element.
+ * @param actor a StyleRuleActor
+ */
+ updateStyleRef(oldItem, item, actor) {
+ this.refMap.delete(oldItem);
+ this.refMap.set(item, actor);
+ }
+
+ /**
+ * Get the StyleRuleActor matching the given rule id or null if no match is found.
+ *
+ * @param {String} ruleId
+ * Actor ID of the StyleRuleActor
+ * @return {StyleRuleActor|null}
+ */
+ getRule(ruleId) {
+ let match = null;
+
+ for (const actor of this.refMap.values()) {
+ if (actor.actorID === ruleId) {
+ match = actor;
+ continue;
+ }
+ }
+
+ return match;
+ }
+
+ /**
+ * Get the computed style for a node.
+ *
+ * @param NodeActor node
+ * @param object options
+ * `filter`: A string filter that affects the "matched" handling.
+ * 'user': Include properties from user style sheets.
+ * 'ua': Include properties from user and user-agent sheets.
+ * Default value is 'ua'
+ * `markMatched`: true if you want the 'matched' property to be added
+ * when a computed property has been modified by a style included
+ * by `filter`.
+ * `onlyMatched`: true if unmatched properties shouldn't be included.
+ * `filterProperties`: An array of properties names that you would like
+ * returned.
+ *
+ * @returns a JSON blob with the following form:
+ * {
+ * "property-name": {
+ * value: "property-value",
+ * priority: "!important" <optional>
+ * matched: <true if there are matched selectors for this value>
+ * },
+ * ...
+ * }
+ */
+ getComputed(node, options) {
+ const ret = Object.create(null);
+
+ this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA;
+ this.cssLogic.highlight(node.rawNode);
+ const computed = this.cssLogic.computedStyle || [];
+
+ Array.prototype.forEach.call(computed, name => {
+ if (
+ Array.isArray(options.filterProperties) &&
+ !options.filterProperties.includes(name)
+ ) {
+ return;
+ }
+ ret[name] = {
+ value: computed.getPropertyValue(name),
+ priority: computed.getPropertyPriority(name) || undefined,
+ };
+ });
+
+ if (options.markMatched || options.onlyMatched) {
+ const matched = this.cssLogic.hasMatchedSelectors(Object.keys(ret));
+ for (const key in ret) {
+ if (matched[key]) {
+ ret[key].matched = options.markMatched ? true : undefined;
+ } else if (options.onlyMatched) {
+ delete ret[key];
+ }
+ }
+ }
+
+ return ret;
+ }
+
+ /**
+ * Get all the fonts from a page.
+ *
+ * @param object options
+ * `includePreviews`: Whether to also return image previews of the fonts.
+ * `previewText`: The text to display in the previews.
+ * `previewFontSize`: The font size of the text in the previews.
+ *
+ * @returns object
+ * object with 'fontFaces', a list of fonts that apply to this node.
+ */
+ getAllUsedFontFaces(options) {
+ const windows = this.inspector.targetActor.windows;
+ let fontsList = [];
+ for (const win of windows) {
+ // Fall back to the documentElement for XUL documents.
+ const node = win.document.body
+ ? win.document.body
+ : win.document.documentElement;
+ fontsList = [...fontsList, ...this.getUsedFontFaces(node, options)];
+ }
+
+ return fontsList;
+ }
+
+ /**
+ * Get the font faces used in an element.
+ *
+ * @param NodeActor node / actual DOM node
+ * The node to get fonts from.
+ * @param object options
+ * `includePreviews`: Whether to also return image previews of the fonts.
+ * `previewText`: The text to display in the previews.
+ * `previewFontSize`: The font size of the text in the previews.
+ *
+ * @returns object
+ * object with 'fontFaces', a list of fonts that apply to this node.
+ */
+ getUsedFontFaces(node, options) {
+ // node.rawNode is defined for NodeActor objects
+ const actualNode = node.rawNode || node;
+ const contentDocument = actualNode.ownerDocument;
+ // We don't get fonts for a node, but for a range
+ const rng = contentDocument.createRange();
+ const isPseudoElement = Boolean(
+ CssLogic.getBindingElementAndPseudo(actualNode).pseudo
+ );
+ if (isPseudoElement) {
+ rng.selectNodeContents(actualNode);
+ } else {
+ rng.selectNode(actualNode);
+ }
+ const fonts = InspectorUtils.getUsedFontFaces(rng);
+ const fontsArray = [];
+
+ for (let i = 0; i < fonts.length; i++) {
+ const font = fonts[i];
+ const fontFace = {
+ name: font.name,
+ CSSFamilyName: font.CSSFamilyName,
+ CSSGeneric: font.CSSGeneric || null,
+ srcIndex: font.srcIndex,
+ URI: font.URI,
+ format: font.format,
+ localName: font.localName,
+ metadata: font.metadata,
+ };
+
+ // If this font comes from a @font-face rule
+ if (font.rule) {
+ const styleActor = new StyleRuleActor(this, font.rule);
+ this.manage(styleActor);
+ fontFace.rule = styleActor;
+ fontFace.ruleText = font.rule.cssText;
+ }
+
+ // Get the weight and style of this font for the preview and sort order
+ let weight = NORMAL_FONT_WEIGHT,
+ style = "";
+ if (font.rule) {
+ weight =
+ font.rule.style.getPropertyValue("font-weight") || NORMAL_FONT_WEIGHT;
+ if (weight == "bold") {
+ weight = BOLD_FONT_WEIGHT;
+ } else if (weight == "normal") {
+ weight = NORMAL_FONT_WEIGHT;
+ }
+ style = font.rule.style.getPropertyValue("font-style") || "";
+ }
+ fontFace.weight = weight;
+ fontFace.style = style;
+
+ if (options.includePreviews) {
+ const opts = {
+ previewText: options.previewText,
+ previewFontSize: options.previewFontSize,
+ fontStyle: weight + " " + style,
+ fillStyle: options.previewFillStyle,
+ };
+ const { dataURL, size } = getFontPreviewData(
+ font.CSSFamilyName,
+ contentDocument,
+ opts
+ );
+ fontFace.preview = {
+ data: new LongStringActor(this.conn, dataURL),
+ size,
+ };
+ }
+
+ if (options.includeVariations && FONT_VARIATIONS_ENABLED) {
+ fontFace.variationAxes = font.getVariationAxes();
+ fontFace.variationInstances = font.getVariationInstances();
+ }
+
+ fontsArray.push(fontFace);
+ }
+
+ // @font-face fonts at the top, then alphabetically, then by weight
+ fontsArray.sort(function (a, b) {
+ return a.weight > b.weight ? 1 : -1;
+ });
+ fontsArray.sort(function (a, b) {
+ if (a.CSSFamilyName == b.CSSFamilyName) {
+ return 0;
+ }
+ return a.CSSFamilyName > b.CSSFamilyName ? 1 : -1;
+ });
+ fontsArray.sort(function (a, b) {
+ if ((a.rule && b.rule) || (!a.rule && !b.rule)) {
+ return 0;
+ }
+ return !a.rule && b.rule ? 1 : -1;
+ });
+
+ return fontsArray;
+ }
+
+ /**
+ * Get a list of selectors that match a given property for a node.
+ *
+ * @param NodeActor node
+ * @param string property
+ * @param object options
+ * `filter`: A string filter that affects the "matched" handling.
+ * 'user': Include properties from user style sheets.
+ * 'ua': Include properties from user and user-agent sheets.
+ * Default value is 'ua'
+ *
+ * @returns a JSON object with the following form:
+ * {
+ * // An ordered list of rules that apply
+ * matched: [{
+ * rule: <rule actorid>,
+ * sourceText: <string>, // The source of the selector, relative
+ * // to the node in question.
+ * selector: <string>, // the selector ID that matched
+ * value: <string>, // the value of the property
+ * status: <int>,
+ * // The status of the match - high numbers are better placed
+ * // to provide styling information:
+ * // 3: Best match, was used.
+ * // 2: Matched, but was overridden.
+ * // 1: Rule from a parent matched.
+ * // 0: Unmatched (never returned in this API)
+ * }, ...],
+ *
+ * // The full form of any domrule referenced.
+ * rules: [ <domrule>, ... ], // The full form of any domrule referenced
+ *
+ * // The full form of any sheets referenced.
+ * sheets: [ <domsheet>, ... ]
+ * }
+ */
+ getMatchedSelectors(node, property, options) {
+ this.cssLogic.sourceFilter = options.filter || SharedCssLogic.FILTER.UA;
+ this.cssLogic.highlight(node.rawNode);
+
+ const rules = new Set();
+
+ const matched = [];
+ const propInfo = this.cssLogic.getPropertyInfo(property);
+ for (const selectorInfo of propInfo.matchedSelectors) {
+ const cssRule = selectorInfo.selector.cssRule;
+ const domRule = cssRule.sourceElement || cssRule.domRule;
+
+ const rule = this._styleRef(domRule);
+ rules.add(rule);
+
+ matched.push({
+ rule,
+ sourceText: this.getSelectorSource(selectorInfo, node.rawNode),
+ selector: selectorInfo.selector.text,
+ name: selectorInfo.property,
+ value: selectorInfo.value,
+ status: selectorInfo.status,
+ });
+ }
+
+ return {
+ matched,
+ rules: [...rules],
+ };
+ }
+
+ // Get a selector source for a CssSelectorInfo relative to a given
+ // node.
+ getSelectorSource(selectorInfo, relativeTo) {
+ let result = selectorInfo.selector.text;
+ if (selectorInfo.inlineStyle) {
+ const source = selectorInfo.sourceElement;
+ if (source === relativeTo) {
+ result = "this";
+ } else {
+ result = CssLogic.getShortName(source);
+ }
+ result += ".style";
+ }
+ return result;
+ }
+
+ /**
+ * Get the set of styles that apply to a given node.
+ * @param NodeActor node
+ * @param object options
+ * `filter`: A string filter that affects the "matched" handling.
+ * 'user': Include properties from user style sheets.
+ * 'ua': Include properties from user and user-agent sheets.
+ * Default value is 'ua'
+ * `inherited`: Include styles inherited from parent nodes.
+ * `matchedSelectors`: Include an array of specific selectors that
+ * caused this rule to match its node.
+ * `skipPseudo`: Exclude styles applied to pseudo elements of the provided node.
+ */
+ async getApplied(node, options) {
+ // Clear any previous references to StyleRuleActor instances for CSS rules.
+ // Assume the consumer has switched context to a new node and no longer
+ // interested in state changes of previous rules.
+ this._observedRules = [];
+ this.selectedElement = node.rawNode;
+
+ if (!node) {
+ return { entries: [] };
+ }
+
+ this.cssLogic.highlight(node.rawNode);
+
+ const entries = this.getAppliedProps(
+ node,
+ this._getAllElementRules(node, undefined, options),
+ options
+ );
+
+ const entryRules = new Set();
+ entries.forEach(entry => {
+ entryRules.add(entry.rule);
+ });
+
+ await Promise.all(entries.map(entry => entry.rule.getAuthoredCssText()));
+
+ // Reference to instances of StyleRuleActor for CSS rules matching the node.
+ // Assume these are used by a consumer which wants to be notified when their
+ // state or declarations change either directly or indirectly.
+ this._observedRules = entryRules;
+
+ return { entries };
+ }
+
+ _hasInheritedProps(style) {
+ const doc = this.inspector.targetActor.window.document;
+ return Array.prototype.some.call(style, prop =>
+ InspectorUtils.isInheritedProperty(doc, prop)
+ );
+ }
+
+ async isPositionEditable(node) {
+ if (!node || node.rawNode.nodeType !== node.rawNode.ELEMENT_NODE) {
+ return false;
+ }
+
+ const props = getDefinedGeometryProperties(node.rawNode);
+
+ // Elements with only `width` and `height` are currently not considered
+ // editable.
+ return (
+ props.has("top") ||
+ props.has("right") ||
+ props.has("left") ||
+ props.has("bottom")
+ );
+ }
+
+ /**
+ * Helper function for getApplied, gets all the rules from a given
+ * element. See getApplied for documentation on parameters.
+ * @param NodeActor node
+ * @param bool inherited
+ * @param object options
+
+ * @return Array The rules for a given element. Each item in the
+ * array has the following signature:
+ * - rule RuleActor
+ * - isSystem Boolean
+ * - inherited Boolean
+ * - pseudoElement String
+ */
+ _getAllElementRules(node, inherited, options) {
+ const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(
+ node.rawNode
+ );
+ const rules = [];
+
+ if (!bindingElement || !bindingElement.style) {
+ return rules;
+ }
+
+ const elementStyle = this._styleRef(bindingElement);
+ const showElementStyles = !inherited && !pseudo;
+ const showInheritedStyles =
+ inherited && this._hasInheritedProps(bindingElement.style);
+
+ const rule = {
+ rule: elementStyle,
+ pseudoElement: null,
+ isSystem: false,
+ inherited: false,
+ };
+
+ // First any inline styles
+ if (showElementStyles) {
+ rules.push(rule);
+ }
+
+ // Now any inherited styles
+ if (showInheritedStyles) {
+ rule.inherited = inherited;
+ rules.push(rule);
+ }
+
+ // Add normal rules. Typically this is passing in the node passed into the
+ // function, unless if that node was ::before/::after. In which case,
+ // it will pass in the parentNode along with "::before"/"::after".
+ this._getElementRules(bindingElement, pseudo, inherited, options).forEach(
+ oneRule => {
+ // The only case when there would be a pseudo here is
+ // ::before/::after, and in this case we want to tell the
+ // view that it belongs to the element (which is a
+ // _moz_generated_content native anonymous element).
+ oneRule.pseudoElement = null;
+ rules.push(oneRule);
+ }
+ );
+
+ // Now any pseudos.
+ if (showElementStyles && !options.skipPseudo) {
+ const relevantPseudoElements = [];
+ for (const readPseudo of PSEUDO_ELEMENTS) {
+ if (!this._pseudoIsRelevant(bindingElement, readPseudo)) {
+ continue;
+ }
+
+ if (readPseudo === "::highlight") {
+ InspectorUtils.getRegisteredCssHighlights(
+ this.inspector.targetActor.window.document,
+ // only active
+ true
+ ).forEach(name => {
+ relevantPseudoElements.push(`::highlight(${name})`);
+ });
+ } else {
+ relevantPseudoElements.push(readPseudo);
+ }
+ }
+
+ for (const readPseudo of relevantPseudoElements) {
+ const pseudoRules = this._getElementRules(
+ bindingElement,
+ readPseudo,
+ inherited,
+ options
+ );
+ rules.push(...pseudoRules);
+ }
+ }
+
+ return rules;
+ }
+
+ _nodeIsTextfieldLike(node) {
+ if (node.nodeName == "TEXTAREA") {
+ return true;
+ }
+ return (
+ node.mozIsTextField &&
+ (node.mozIsTextField(false) || node.type == "number")
+ );
+ }
+
+ _nodeIsButtonLike(node) {
+ if (node.nodeName == "BUTTON") {
+ return true;
+ }
+ return (
+ node.nodeName == "INPUT" &&
+ ["submit", "color", "button"].includes(node.type)
+ );
+ }
+
+ _nodeIsListItem(node) {
+ const display = CssLogic.getComputedStyle(node).getPropertyValue("display");
+ // This is written this way to handle `inline list-item` and such.
+ return display.split(" ").includes("list-item");
+ }
+
+ // eslint-disable-next-line complexity
+ _pseudoIsRelevant(node, pseudo) {
+ switch (pseudo) {
+ case "::after":
+ case "::before":
+ case "::first-letter":
+ case "::first-line":
+ case "::selection":
+ case "::highlight":
+ return true;
+ case "::marker":
+ return this._nodeIsListItem(node);
+ case "::backdrop":
+ return node.matches(":modal");
+ case "::cue":
+ return node.nodeName == "VIDEO";
+ case "::file-selector-button":
+ return node.nodeName == "INPUT" && node.type == "file";
+ case "::placeholder":
+ case "::-moz-placeholder":
+ return this._nodeIsTextfieldLike(node);
+ case "::-moz-focus-inner":
+ return this._nodeIsButtonLike(node);
+ case "::-moz-meter-bar":
+ return node.nodeName == "METER";
+ case "::-moz-progress-bar":
+ return node.nodeName == "PROGRESS";
+ case "::-moz-color-swatch":
+ return node.nodeName == "INPUT" && node.type == "color";
+ case "::-moz-range-progress":
+ case "::-moz-range-thumb":
+ case "::-moz-range-track":
+ return node.nodeName == "INPUT" && node.type == "range";
+ default:
+ throw Error("Unhandled pseudo-element " + pseudo);
+ }
+ }
+
+ /**
+ * Helper function for _getAllElementRules, returns the rules from a given
+ * element. See getApplied for documentation on parameters.
+ * @param DOMNode node
+ * @param string pseudo
+ * @param DOMNode inherited
+ * @param object options
+ *
+ * @returns Array
+ */
+ _getElementRules(node, pseudo, inherited, options) {
+ const domRules = InspectorUtils.getCSSStyleRules(
+ node,
+ pseudo,
+ CssLogic.hasVisitedState(node)
+ );
+
+ if (!domRules) {
+ return [];
+ }
+
+ const rules = [];
+
+ const doc = this.inspector.targetActor.window.document;
+
+ // getCSSStyleRules returns ordered from least-specific to
+ // most-specific.
+ for (let i = domRules.length - 1; i >= 0; i--) {
+ const domRule = domRules[i];
+
+ const isSystem = SharedCssLogic.isAgentStylesheet(
+ domRule.parentStyleSheet
+ );
+
+ if (isSystem && options.filter != SharedCssLogic.FILTER.UA) {
+ continue;
+ }
+
+ if (inherited) {
+ // Don't include inherited rules if none of its properties
+ // are inheritable.
+ const hasInherited = [...domRule.style].some(prop =>
+ InspectorUtils.isInheritedProperty(doc, prop)
+ );
+ if (!hasInherited) {
+ continue;
+ }
+ }
+
+ const ruleActor = this._styleRef(domRule);
+
+ rules.push({
+ rule: ruleActor,
+ inherited,
+ isSystem,
+ pseudoElement: pseudo,
+ });
+ }
+ return rules;
+ }
+
+ /**
+ * Given a node and a CSS rule, walk up the DOM looking for a
+ * matching element rule. Return an array of all found entries, in
+ * the form generated by _getAllElementRules. Note that this will
+ * always return an array of either zero or one element.
+ *
+ * @param {NodeActor} node the node
+ * @param {CSSStyleRule} filterRule the rule to filter for
+ * @return {Array} array of zero or one elements; if one, the element
+ * is the entry as returned by _getAllElementRules.
+ */
+ findEntryMatchingRule(node, filterRule) {
+ const options = { matchedSelectors: true, inherited: true };
+ let entries = [];
+ let parent = this.walker.parentNode(node);
+ while (parent && parent.rawNode.nodeType != Node.DOCUMENT_NODE) {
+ entries = entries.concat(
+ this._getAllElementRules(parent, parent, options)
+ );
+ parent = this.walker.parentNode(parent);
+ }
+
+ return entries.filter(entry => entry.rule.rawRule === filterRule);
+ }
+
+ /**
+ * Helper function for getApplied that fetches a set of style properties that
+ * apply to the given node and associated rules
+ * @param NodeActor node
+ * @param object options
+ * `filter`: A string filter that affects the "matched" handling.
+ * 'user': Include properties from user style sheets.
+ * 'ua': Include properties from user and user-agent sheets.
+ * Default value is 'ua'
+ * `inherited`: Include styles inherited from parent nodes.
+ * `matchedSelectors`: Include an array of specific (desugared) selectors that
+ * caused this rule to match its node.
+ * `skipPseudo`: Exclude styles applied to pseudo elements of the provided node.
+ * @param array entries
+ * List of appliedstyle objects that lists the rules that apply to the
+ * node. If adding a new rule to the stylesheet, only the new rule entry
+ * is provided and only the style properties that apply to the new
+ * rule is fetched.
+ * @returns Array of rule entries that applies to the given node and its associated rules.
+ */
+ getAppliedProps(node, entries, options) {
+ if (options.inherited) {
+ let parent = this.walker.parentNode(node);
+ while (parent && parent.rawNode.nodeType != Node.DOCUMENT_NODE) {
+ entries = entries.concat(
+ this._getAllElementRules(parent, parent, options)
+ );
+ parent = this.walker.parentNode(parent);
+ }
+ }
+
+ if (options.matchedSelectors) {
+ for (const entry of entries) {
+ if (entry.rule.type === ELEMENT_STYLE) {
+ continue;
+ }
+
+ const domRule = entry.rule.rawRule;
+ const desugaredSelectors = entry.rule.getDesugaredSelectors();
+ const element = entry.inherited
+ ? entry.inherited.rawNode
+ : node.rawNode;
+
+ const { bindingElement, pseudo } =
+ CssLogic.getBindingElementAndPseudo(element);
+ const relevantLinkVisited = CssLogic.hasVisitedState(bindingElement);
+ entry.matchedDesugaredSelectors = [];
+
+ for (let i = 0; i < desugaredSelectors.length; i++) {
+ if (
+ domRule.selectorMatchesElement(
+ i,
+ bindingElement,
+ pseudo,
+ relevantLinkVisited
+ )
+ ) {
+ entry.matchedDesugaredSelectors.push(desugaredSelectors[i]);
+ }
+ }
+ }
+ }
+
+ // Add all the keyframes rule associated with the element
+ const computedStyle = this.cssLogic.computedStyle;
+ if (computedStyle) {
+ let animationNames = computedStyle.animationName.split(",");
+ animationNames = animationNames.map(name => name.trim());
+
+ if (animationNames) {
+ // Traverse through all the available keyframes rule and add
+ // the keyframes rule that matches the computed animation name
+ for (const keyframesRule of this.cssLogic.keyframesRules) {
+ if (animationNames.indexOf(keyframesRule.name) > -1) {
+ for (const rule of keyframesRule.cssRules) {
+ entries.push({
+ rule: this._styleRef(rule),
+ keyframes: this._styleRef(keyframesRule),
+ });
+ }
+ }
+ }
+ }
+ }
+
+ return entries;
+ }
+
+ /**
+ * Get layout-related information about a node.
+ * This method returns an object with properties giving information about
+ * the node's margin, border, padding and content region sizes, as well
+ * as information about the type of box, its position, z-index, etc...
+ * @param {NodeActor} node
+ * @param {Object} options The only available option is autoMargins.
+ * If set to true, the element's margins will receive an extra check to see
+ * whether they are set to "auto" (knowing that the computed-style in this
+ * case would return "0px").
+ * The returned object will contain an extra property (autoMargins) listing
+ * all margins that are set to auto, e.g. {top: "auto", left: "auto"}.
+ * @return {Object}
+ */
+ getLayout(node, options) {
+ this.cssLogic.highlight(node.rawNode);
+
+ const layout = {};
+
+ // First, we update the first part of the box model view, with
+ // the size of the element.
+
+ const clientRect = node.rawNode.getBoundingClientRect();
+ layout.width = parseFloat(clientRect.width.toPrecision(6));
+ layout.height = parseFloat(clientRect.height.toPrecision(6));
+
+ // We compute and update the values of margins & co.
+ const style = CssLogic.getComputedStyle(node.rawNode);
+ for (const prop of [
+ "position",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "margin-top",
+ "margin-right",
+ "margin-bottom",
+ "margin-left",
+ "padding-top",
+ "padding-right",
+ "padding-bottom",
+ "padding-left",
+ "border-top-width",
+ "border-right-width",
+ "border-bottom-width",
+ "border-left-width",
+ "z-index",
+ "box-sizing",
+ "display",
+ "float",
+ "line-height",
+ ]) {
+ layout[prop] = style.getPropertyValue(prop);
+ }
+
+ if (options.autoMargins) {
+ layout.autoMargins = this.processMargins(this.cssLogic);
+ }
+
+ for (const i in this.map) {
+ const property = this.map[i].property;
+ this.map[i].value = parseFloat(style.getPropertyValue(property));
+ }
+
+ return layout;
+ }
+
+ /**
+ * Find 'auto' margin properties.
+ */
+ processMargins(cssLogic) {
+ const margins = {};
+
+ for (const prop of ["top", "bottom", "left", "right"]) {
+ const info = cssLogic.getPropertyInfo("margin-" + prop);
+ const selectors = info.matchedSelectors;
+ if (selectors && !!selectors.length && selectors[0].value == "auto") {
+ margins[prop] = "auto";
+ }
+ }
+
+ return margins;
+ }
+
+ /**
+ * On page navigation, tidy up remaining objects.
+ */
+ onFrameUnload() {
+ this.styleElements = new WeakMap();
+ }
+
+ _onStylesheetUpdated({ resourceId, updateKind, updates = {} }) {
+ if (updateKind != "style-applied") {
+ return;
+ }
+ const kind = updates.event.kind;
+ // Duplicate refMap content before looping as onStyleApplied may mutate it
+ for (const styleActor of [...this.refMap.values()]) {
+ // Ignore StyleRuleActor that don't have a parent stylesheet.
+ // i.e. actor whose type is ELEMENT_STYLE.
+ if (!styleActor._parentSheet) {
+ continue;
+ }
+ const resId = this.styleSheetsManager.getStyleSheetResourceId(
+ styleActor._parentSheet
+ );
+ if (resId === resourceId) {
+ styleActor.onStyleApplied(kind);
+ }
+ }
+ this._styleApplied(kind);
+ }
+
+ /**
+ * Helper function for adding a new rule and getting its applied style
+ * properties
+ * @param NodeActor node
+ * @param CSSStyleRule rule
+ * @returns Array containing its applied style properties
+ */
+ getNewAppliedProps(node, rule) {
+ const ruleActor = this._styleRef(rule);
+ return this.getAppliedProps(node, [{ rule: ruleActor }], {
+ matchedSelectors: true,
+ });
+ }
+
+ /**
+ * Adds a new rule, and returns the new StyleRuleActor.
+ * @param {NodeActor} node
+ * @param {String} pseudoClasses The list of pseudo classes to append to the
+ * new selector.
+ * @returns {StyleRuleActor} the new rule
+ */
+ async addNewRule(node, pseudoClasses) {
+ let sheet = null;
+ const doc = node.rawNode.ownerDocument;
+ if (
+ this.styleElements.has(doc) &&
+ this.styleElements.get(doc).ownerNode?.isConnected
+ ) {
+ sheet = this.styleElements.get(doc);
+ } else {
+ sheet = await this.styleSheetsManager.addStyleSheet(doc);
+ this.styleElements.set(doc, sheet);
+ }
+
+ const cssRules = sheet.cssRules;
+ const rawNode = node.rawNode;
+ const classes = [...rawNode.classList];
+
+ let selector;
+ if (rawNode.id) {
+ selector = "#" + CSS.escape(rawNode.id);
+ } else if (classes.length) {
+ selector = "." + classes.map(c => CSS.escape(c)).join(".");
+ } else {
+ selector = rawNode.localName;
+ }
+
+ if (pseudoClasses && pseudoClasses.length) {
+ selector += pseudoClasses.join("");
+ }
+
+ const index = sheet.insertRule(selector + " {}", cssRules.length);
+
+ const resourceId = this.styleSheetsManager.getStyleSheetResourceId(sheet);
+ let authoredText = await this.styleSheetsManager.getText(resourceId);
+ authoredText += "\n" + selector + " {\n" + "}";
+ await this.styleSheetsManager.setStyleSheetText(resourceId, authoredText);
+
+ const cssRule = sheet.cssRules.item(index);
+ const ruleActor = this._styleRef(cssRule, true);
+
+ TrackChangeEmitter.trackChange({
+ ...ruleActor.metadata,
+ type: "rule-add",
+ add: null,
+ remove: null,
+ selector,
+ });
+
+ return { entries: this.getNewAppliedProps(node, cssRule) };
+ }
+
+ /**
+ * Cause all StyleRuleActor instances of observed CSS rules to check whether the
+ * states of their declarations have changed.
+ *
+ * Observed rules are the latest rules returned by a call to PageStyleActor.getApplied()
+ *
+ * This is necessary because changes in one rule can cause the declarations in another
+ * to not be applicable (inactive CSS). The observers of those rules should be notified.
+ * Rules will fire a "rule-updated" event if any of their declarations changed state.
+ *
+ * Call this method whenever a CSS rule is mutated:
+ * - a CSS declaration is added/changed/disabled/removed
+ * - a selector is added/changed/removed
+ *
+ * @param {Array<StyleRuleActor>} rulesToForceRefresh: An array of rules that,
+ * if observed, should be refreshed even if the state of their declaration
+ * didn't change.
+ */
+ refreshObservedRules(rulesToForceRefresh) {
+ for (const rule of this._observedRules) {
+ const force = rulesToForceRefresh && rulesToForceRefresh.includes(rule);
+ rule.maybeRefresh(force);
+ }
+ }
+
+ /**
+ * Get an array of existing attribute values in a node document.
+ *
+ * @param {String} search: A string to filter attribute value on.
+ * @param {String} attributeType: The type of attribute we want to retrieve the values.
+ * @param {Element} node: The element we want to get possible attributes for. This will
+ * be used to get the document where the search is happening.
+ * @returns {Array<String>} An array of strings
+ */
+ getAttributesInOwnerDocument(search, attributeType, node) {
+ if (!search) {
+ throw new Error("search is mandatory");
+ }
+
+ // In a non-fission world, a node from an iframe shares the same `rootNode` as a node
+ // in the top-level document. So here we need to retrieve the document from the node
+ // in parameter in order to retrieve the right document.
+ // This may change once we have a dedicated walker for every target in a tab, as we'll
+ // be able to directly talk to the "right" walker actor.
+ const targetDocument = node.rawNode.ownerDocument;
+
+ // We store the result in a Set which will contain the attribute value
+ const result = new Set();
+ const lcSearch = search.toLowerCase();
+ this._collectAttributesFromDocumentDOM(
+ result,
+ lcSearch,
+ attributeType,
+ targetDocument,
+ node.rawNode
+ );
+ this._collectAttributesFromDocumentStyleSheets(
+ result,
+ lcSearch,
+ attributeType,
+ targetDocument
+ );
+
+ return Array.from(result).sort();
+ }
+
+ /**
+ * Collect attribute values from the document DOM tree, matching the passed filter and
+ * type, to the result Set.
+ *
+ * @param {Set<String>} result: A Set to which the results will be added.
+ * @param {String} search: A string to filter attribute value on.
+ * @param {String} attributeType: The type of attribute we want to retrieve the values.
+ * @param {Document} targetDocument: The document the search occurs in.
+ * @param {Node} currentNode: The current element rawNode
+ */
+ _collectAttributesFromDocumentDOM(
+ result,
+ search,
+ attributeType,
+ targetDocument,
+ nodeRawNode
+ ) {
+ // In order to retrieve attributes from DOM elements in the document, we're going to
+ // do a query on the root node using attributes selector, to directly get the elements
+ // matching the attributes we're looking for.
+
+ // For classes, we need something a bit different as the className we're looking
+ // for might not be the first in the attribute value, meaning we can't use the
+ // "attribute starts with X" selector.
+ const attributeSelectorPositionChar = attributeType === "class" ? "*" : "^";
+ const selector = `[${attributeType}${attributeSelectorPositionChar}=${search} i]`;
+
+ const matchingElements = targetDocument.querySelectorAll(selector);
+
+ for (const element of matchingElements) {
+ if (element === nodeRawNode) {
+ return;
+ }
+ // For class attribute, we need to add the elements of the classList that match
+ // the filter string.
+ if (attributeType === "class") {
+ for (const cls of element.classList) {
+ if (!result.has(cls) && cls.toLowerCase().startsWith(search)) {
+ result.add(cls);
+ }
+ }
+ } else {
+ const { value } = element.attributes[attributeType];
+ // For other attributes, we can directly use the attribute value.
+ result.add(value);
+ }
+ }
+ }
+
+ /**
+ * Collect attribute values from the document stylesheets, matching the passed filter
+ * and type, to the result Set.
+ *
+ * @param {Set<String>} result: A Set to which the results will be added.
+ * @param {String} search: A string to filter attribute value on.
+ * @param {String} attributeType: The type of attribute we want to retrieve the values.
+ * It only supports "class" and "id" at the moment.
+ * @param {Document} targetDocument: The document the search occurs in.
+ */
+ _collectAttributesFromDocumentStyleSheets(
+ result,
+ search,
+ attributeType,
+ targetDocument
+ ) {
+ if (attributeType !== "class" && attributeType !== "id") {
+ return;
+ }
+
+ // We loop through all the stylesheets and their rules, recursively so we can go through
+ // nested rules, and then use the lexer to only get the attributes we're looking for.
+ const traverseRules = ruleList => {
+ for (const rule of ruleList) {
+ this._collectAttributesFromRule(result, rule, search, attributeType);
+ if (rule.cssRules) {
+ traverseRules(rule.cssRules);
+ }
+ }
+ };
+ for (const styleSheet of targetDocument.styleSheets) {
+ traverseRules(styleSheet.rules);
+ }
+ }
+
+ /**
+ * Collect attribute values from the rule, matching the passed filter and type, to the
+ * result Set.
+ *
+ * @param {Set<String>} result: A Set to which the results will be added.
+ * @param {Rule} rule: The rule the search occurs in.
+ * @param {String} search: A string to filter attribute value on.
+ * @param {String} attributeType: The type of attribute we want to retrieve the values.
+ * It only supports "class" and "id" at the moment.
+ */
+ _collectAttributesFromRule(result, rule, search, attributeType) {
+ const shouldRetrieveClasses = attributeType === "class";
+ const shouldRetrieveIds = attributeType === "id";
+
+ const { selectorText } = rule;
+ // If there's no selectorText, or if the selectorText does not include the
+ // filter, we can bail out.
+ if (!selectorText || !selectorText.toLowerCase().includes(search)) {
+ return;
+ }
+
+ // Check if we should parse the selectorText (do we need to check for class/id and
+ // if so, does the selector contains class/id related chars).
+ const parseForClasses =
+ shouldRetrieveClasses &&
+ selectorText.toLowerCase().includes(`.${search}`);
+ const parseForIds =
+ shouldRetrieveIds && selectorText.toLowerCase().includes(`#${search}`);
+
+ if (!parseForClasses && !parseForIds) {
+ return;
+ }
+
+ const lexer = getCSSLexer(selectorText);
+ let token;
+ while ((token = lexer.nextToken())) {
+ if (
+ token.tokenType === "symbol" &&
+ ((shouldRetrieveClasses && token.text === ".") ||
+ (shouldRetrieveIds && token.text === "#"))
+ ) {
+ token = lexer.nextToken();
+ if (
+ token.tokenType === "ident" &&
+ token.text.toLowerCase().startsWith(search)
+ ) {
+ result.add(token.text);
+ }
+ }
+ }
+ }
+}
+exports.PageStyleActor = PageStyleActor;
diff --git a/devtools/server/actors/pause-scoped.js b/devtools/server/actors/pause-scoped.js
new file mode 100644
index 0000000000..dc1f2700ee
--- /dev/null
+++ b/devtools/server/actors/pause-scoped.js
@@ -0,0 +1,80 @@
+/* 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 { ObjectActor } = require("resource://devtools/server/actors/object.js");
+
+class PauseScopedObjectActor extends ObjectActor {
+ /**
+ * Creates a pause-scoped actor for the specified object.
+ * @see ObjectActor
+ */
+ constructor(obj, hooks, conn) {
+ super(obj, hooks, conn);
+
+ this.hooks.promote = hooks.promote;
+ this.hooks.isThreadLifetimePool = hooks.isThreadLifetimePool;
+
+ const guardWithPaused = [
+ "decompile",
+ "displayString",
+ "ownPropertyNames",
+ "parameterNames",
+ "property",
+ "prototype",
+ "prototypeAndProperties",
+ "scope",
+ ];
+
+ for (const methodName of guardWithPaused) {
+ this[methodName] = this.withPaused(this[methodName]);
+ }
+
+ /**
+ * Handle a protocol request to promote a pause-lifetime grip to a
+ * thread-lifetime grip.
+ */
+ this.threadGrip = this.withPaused(function () {
+ this.hooks.promote();
+ return {};
+ });
+ }
+
+ isPaused() {
+ return this.threadActor ? this.threadActor.state === "paused" : true;
+ }
+
+ withPaused(method) {
+ return function () {
+ if (this.isPaused()) {
+ return method.apply(this, arguments);
+ }
+
+ return {
+ error: "wrongState",
+ message:
+ this.constructor.name +
+ " actors can only be accessed while the thread is paused.",
+ };
+ };
+ }
+
+ /**
+ * Handle a protocol request to release a thread-lifetime grip.
+ */
+ destroy() {
+ if (this.hooks.isThreadLifetimePool()) {
+ return {
+ error: "notReleasable",
+ message: "Only thread-lifetime actors can be released.",
+ };
+ }
+
+ super.destroy();
+ return null;
+ }
+}
+
+exports.PauseScopedObjectActor = PauseScopedObjectActor;
diff --git a/devtools/server/actors/perf.js b/devtools/server/actors/perf.js
new file mode 100644
index 0000000000..3f561256c9
--- /dev/null
+++ b/devtools/server/actors/perf.js
@@ -0,0 +1,187 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { perfSpec } = require("resource://devtools/shared/specs/perf.js");
+
+loader.lazyRequireGetter(
+ this,
+ "RecordingUtils",
+ "resource://devtools/shared/performance-new/recording-utils.js"
+);
+
+// Some platforms are built without the Gecko Profiler.
+const IS_SUPPORTED_PLATFORM = "nsIProfiler" in Ci;
+
+/**
+ * The PerfActor wraps the Gecko Profiler interface (aka Services.profiler).
+ */
+exports.PerfActor = class PerfActor extends Actor {
+ constructor(conn) {
+ super(conn, perfSpec);
+
+ // Only setup the observers on a supported platform.
+ if (IS_SUPPORTED_PLATFORM) {
+ this._observer = {
+ observe: this._observe.bind(this),
+ };
+ Services.obs.addObserver(this._observer, "profiler-started");
+ Services.obs.addObserver(this._observer, "profiler-stopped");
+ }
+ }
+
+ destroy() {
+ super.destroy();
+
+ if (!IS_SUPPORTED_PLATFORM) {
+ return;
+ }
+ Services.obs.removeObserver(this._observer, "profiler-started");
+ Services.obs.removeObserver(this._observer, "profiler-stopped");
+ }
+
+ startProfiler(options) {
+ if (!IS_SUPPORTED_PLATFORM) {
+ return false;
+ }
+
+ // For a quick implementation, decide on some default values. These may need
+ // to be tweaked or made configurable as needed.
+ const settings = {
+ entries: options.entries || 1000000,
+ duration: options.duration || 0,
+ interval: options.interval || 1,
+ features: options.features || [
+ "js",
+ "stackwalk",
+ "cpu",
+ "responsiveness",
+ ],
+ threads: options.threads || ["GeckoMain", "Compositor"],
+ activeTabID: RecordingUtils.getActiveBrowserID(),
+ };
+
+ try {
+ // This can throw an error if the profiler is in the wrong state.
+ Services.profiler.StartProfiler(
+ settings.entries,
+ settings.interval,
+ settings.features,
+ settings.threads,
+ settings.activeTabID,
+ settings.duration
+ );
+ } catch (e) {
+ // In case any errors get triggered, bailout with a false.
+ return false;
+ }
+
+ return true;
+ }
+
+ stopProfilerAndDiscardProfile() {
+ if (!IS_SUPPORTED_PLATFORM) {
+ return;
+ }
+ Services.profiler.StopProfiler();
+ }
+
+ /**
+ * @type {string} debugPath
+ * @type {string} breakpadId
+ * @returns {Promise<[number[], number[], number[]]>}
+ */
+ async getSymbolTable(debugPath, breakpadId) {
+ const [addr, index, buffer] = await Services.profiler.getSymbolTable(
+ debugPath,
+ breakpadId
+ );
+ // The protocol does not support the transfer of typed arrays, so we convert
+ // these typed arrays to plain JS arrays of numbers now.
+ // Our return value type is declared as "array:array:number".
+ return [Array.from(addr), Array.from(index), Array.from(buffer)];
+ }
+
+ async getProfileAndStopProfiler() {
+ if (!IS_SUPPORTED_PLATFORM) {
+ return null;
+ }
+
+ // Pause profiler before we collect the profile, so that we don't capture
+ // more samples while the parent process or android threads wait for subprocess profiles.
+ Services.profiler.Pause();
+
+ let profile;
+ try {
+ // Attempt to pull out the data.
+ profile = await Services.profiler.getProfileDataAsync();
+
+ if (Object.keys(profile).length === 0) {
+ console.error(
+ "An empty object was received from getProfileDataAsync.getProfileDataAsync(), " +
+ "meaning that a profile could not successfully be serialized and captured."
+ );
+ profile = null;
+ }
+ } catch (e) {
+ // Explicitly set the profile to null if there as an error.
+ profile = null;
+ console.error(`There was an error fetching a profile`, e);
+ }
+
+ // Stop and discard the buffers.
+ Services.profiler.StopProfiler();
+
+ // Returns a profile when successful, and null when there is an error.
+ return profile;
+ }
+
+ isActive() {
+ if (!IS_SUPPORTED_PLATFORM) {
+ return false;
+ }
+ return Services.profiler.IsActive();
+ }
+
+ isSupportedPlatform() {
+ return IS_SUPPORTED_PLATFORM;
+ }
+
+ /**
+ * Watch for events that happen within the browser. These can affect the
+ * current availability and state of the Gecko Profiler.
+ */
+ _observe(subject, topic, _data) {
+ // Note! If emitting new events make sure and update the list of bridged
+ // events in the perf actor.
+ switch (topic) {
+ case "profiler-started":
+ const param = subject.QueryInterface(Ci.nsIProfilerStartParams);
+ this.emit(
+ topic,
+ param.entries,
+ param.interval,
+ param.features,
+ param.duration,
+ param.activeTabID
+ );
+ break;
+ case "profiler-stopped":
+ this.emit(topic);
+ break;
+ }
+ }
+
+ /**
+ * Lists the supported features of the profiler for the current browser.
+ * @returns {string[]}
+ */
+ getSupportedFeatures() {
+ if (!IS_SUPPORTED_PLATFORM) {
+ return [];
+ }
+ return Services.profiler.GetFeatures();
+ }
+};
diff --git a/devtools/server/actors/preference.js b/devtools/server/actors/preference.js
new file mode 100644
index 0000000000..3435fe9eb1
--- /dev/null
+++ b/devtools/server/actors/preference.js
@@ -0,0 +1,108 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ preferenceSpec,
+} = require("resource://devtools/shared/specs/preference.js");
+
+const { PREF_STRING, PREF_INT, PREF_BOOL } = Services.prefs;
+
+function ensurePrefType(name, expectedType) {
+ const type = Services.prefs.getPrefType(name);
+ if (type !== expectedType) {
+ throw new Error(`preference is not of the right type: ${name}`);
+ }
+}
+
+/**
+ * Normally the preferences are set using Services.prefs, but this actor allows
+ * a devtools client to set preferences on the debuggee. This is particularly useful
+ * when remote debugging, and the preferences should persist to the remote target
+ * and not to the client. If used for a local target, it effectively behaves the same
+ * as using Services.prefs.
+ *
+ * This actor is used as a global-scoped actor, targeting the entire browser, not an
+ * individual tab.
+ */
+class PreferenceActor extends Actor {
+ constructor(conn) {
+ super(conn, preferenceSpec);
+ }
+ getTraits() {
+ // The *Pref traits are used to know if remote-debugging bugs related to
+ // specific preferences are fixed on the server or if the client should set
+ // default values for them. See the about:debugging module
+ // runtime-default-preferences.js
+ return {};
+ }
+
+ getBoolPref(name) {
+ ensurePrefType(name, PREF_BOOL);
+ return Services.prefs.getBoolPref(name);
+ }
+
+ getCharPref(name) {
+ ensurePrefType(name, PREF_STRING);
+ return Services.prefs.getCharPref(name);
+ }
+
+ getIntPref(name) {
+ ensurePrefType(name, PREF_INT);
+ return Services.prefs.getIntPref(name);
+ }
+
+ getAllPrefs() {
+ const prefs = {};
+ Services.prefs.getChildList("").forEach(function (name, index) {
+ // append all key/value pairs into a huge json object.
+ try {
+ let value;
+ switch (Services.prefs.getPrefType(name)) {
+ case PREF_STRING:
+ value = Services.prefs.getCharPref(name);
+ break;
+ case PREF_INT:
+ value = Services.prefs.getIntPref(name);
+ break;
+ case PREF_BOOL:
+ value = Services.prefs.getBoolPref(name);
+ break;
+ default:
+ }
+ prefs[name] = {
+ value,
+ hasUserValue: Services.prefs.prefHasUserValue(name),
+ };
+ } catch (e) {
+ // pref exists but has no user or default value
+ }
+ });
+ return prefs;
+ }
+
+ setBoolPref(name, value) {
+ Services.prefs.setBoolPref(name, value);
+ Services.prefs.savePrefFile(null);
+ }
+
+ setCharPref(name, value) {
+ Services.prefs.setCharPref(name, value);
+ Services.prefs.savePrefFile(null);
+ }
+
+ setIntPref(name, value) {
+ Services.prefs.setIntPref(name, value);
+ Services.prefs.savePrefFile(null);
+ }
+
+ clearUserPref(name) {
+ Services.prefs.clearUserPref(name);
+ Services.prefs.savePrefFile(null);
+ }
+}
+
+exports.PreferenceActor = PreferenceActor;
diff --git a/devtools/server/actors/process.js b/devtools/server/actors/process.js
new file mode 100644
index 0000000000..2ed2da64c0
--- /dev/null
+++ b/devtools/server/actors/process.js
@@ -0,0 +1,76 @@
+/* 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.lazyGetter(this, "ppmm", () => {
+ return Cc["@mozilla.org/parentprocessmessagemanager;1"].getService();
+});
+
+class ProcessActorList {
+ constructor() {
+ this._actors = new Map();
+ this._onListChanged = null;
+ this._mustNotify = false;
+ this._hasObserver = false;
+ }
+
+ getList() {
+ const processes = [];
+ for (let i = 0; i < ppmm.childCount; i++) {
+ const mm = ppmm.getChildAt(i);
+ processes.push({
+ // An ID of zero is always used for the parent. It would be nice to fix
+ // this so that the pid is also used for the parent, see bug 1587443.
+ id: mm.isInProcess ? 0 : mm.osPid,
+ parent: mm.isInProcess,
+ // TODO: exposes process message manager on frameloaders in order to compute this
+ tabCount: undefined,
+ });
+ }
+ this._mustNotify = true;
+ this._checkListening();
+
+ return processes;
+ }
+
+ get onListChanged() {
+ return this._onListChanged;
+ }
+
+ set onListChanged(onListChanged) {
+ if (typeof onListChanged !== "function" && onListChanged !== null) {
+ throw new Error("onListChanged must be either a function or null.");
+ }
+ if (onListChanged === this._onListChanged) {
+ return;
+ }
+
+ this._onListChanged = onListChanged;
+ this._checkListening();
+ }
+
+ _checkListening() {
+ if (this._onListChanged !== null && this._mustNotify) {
+ if (!this._hasObserver) {
+ Services.obs.addObserver(this, "ipc:content-created");
+ Services.obs.addObserver(this, "ipc:content-shutdown");
+ this._hasObserver = true;
+ }
+ } else if (this._hasObserver) {
+ Services.obs.removeObserver(this, "ipc:content-created");
+ Services.obs.removeObserver(this, "ipc:content-shutdown");
+ this._hasObserver = false;
+ }
+ }
+
+ observe() {
+ if (this._mustNotify) {
+ this._onListChanged();
+ this._mustNotify = false;
+ }
+ }
+}
+
+exports.ProcessActorList = ProcessActorList;
diff --git a/devtools/server/actors/reflow.js b/devtools/server/actors/reflow.js
new file mode 100644
index 0000000000..3eda67cc8c
--- /dev/null
+++ b/devtools/server/actors/reflow.js
@@ -0,0 +1,516 @@
+/* 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";
+
+/**
+ * About the types of objects in this file:
+ *
+ * - ReflowActor: the actor class used for protocol purposes.
+ * Mostly empty, just gets an instance of LayoutChangesObserver and forwards
+ * its "reflows" events to clients.
+ *
+ * - LayoutChangesObserver: extends Observable and uses the ReflowObserver, to
+ * track reflows on the page.
+ * Used by the LayoutActor, but is also exported on the module, so can be used
+ * by any other actor that needs it.
+ *
+ * - Observable: A utility parent class, meant at being extended by classes that
+ * need a to observe something on the targetActor's windows.
+ *
+ * - Dedicated observers: There's only one of them for now: ReflowObserver which
+ * listens to reflow events via the docshell,
+ * These dedicated classes are used by the LayoutChangesObserver.
+ */
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const { reflowSpec } = require("resource://devtools/shared/specs/reflow.js");
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+/**
+ * The reflow actor tracks reflows and emits events about them.
+ */
+exports.ReflowActor = class ReflowActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, reflowSpec);
+
+ this.targetActor = targetActor;
+ this._onReflow = this._onReflow.bind(this);
+ this.observer = getLayoutChangesObserver(targetActor);
+ this._isStarted = false;
+ }
+
+ destroy() {
+ this.stop();
+ releaseLayoutChangesObserver(this.targetActor);
+ this.observer = null;
+ this.targetActor = null;
+
+ super.destroy();
+ }
+
+ /**
+ * Start tracking reflows and sending events to clients about them.
+ * This is a oneway method, do not expect a response and it won't return a
+ * promise.
+ */
+ start() {
+ if (!this._isStarted) {
+ this.observer.on("reflows", this._onReflow);
+ this._isStarted = true;
+ }
+ }
+
+ /**
+ * Stop tracking reflows and sending events to clients about them.
+ * This is a oneway method, do not expect a response and it won't return a
+ * promise.
+ */
+ stop() {
+ if (this._isStarted) {
+ this.observer.off("reflows", this._onReflow);
+ this._isStarted = false;
+ }
+ }
+
+ _onReflow(reflows) {
+ if (this._isStarted) {
+ this.emit("reflows", reflows);
+ }
+ }
+};
+
+/**
+ * Base class for all sorts of observers that need to listen to events on the
+ * targetActor's windows.
+ * @param {WindowGlobalTargetActor} targetActor
+ * @param {Function} callback Executed everytime the observer observes something
+ */
+class Observable {
+ constructor(targetActor, callback) {
+ this.targetActor = targetActor;
+ this.callback = callback;
+
+ this._onWindowReady = this._onWindowReady.bind(this);
+ this._onWindowDestroyed = this._onWindowDestroyed.bind(this);
+
+ this.targetActor.on("window-ready", this._onWindowReady);
+ this.targetActor.on("window-destroyed", this._onWindowDestroyed);
+ }
+
+ /**
+ * Is the observer currently observing
+ */
+ isObserving = false;
+
+ /**
+ * Stop observing and detroy this observer instance
+ */
+ destroy() {
+ if (this.isDestroyed) {
+ return;
+ }
+ this.isDestroyed = true;
+
+ this.stop();
+
+ this.targetActor.off("window-ready", this._onWindowReady);
+ this.targetActor.off("window-destroyed", this._onWindowDestroyed);
+
+ this.callback = null;
+ this.targetActor = null;
+ }
+
+ /**
+ * Start observing whatever it is this observer is supposed to observe
+ */
+ start() {
+ if (this.isObserving) {
+ return;
+ }
+ this.isObserving = true;
+
+ this._startListeners(this.targetActor.windows);
+ }
+
+ /**
+ * Stop observing
+ */
+ stop() {
+ if (!this.isObserving) {
+ return;
+ }
+ this.isObserving = false;
+
+ if (!this.targetActor.isDestroyed() && this.targetActor.docShell) {
+ // It's only worth stopping if the targetActor is still active
+ this._stopListeners(this.targetActor.windows);
+ }
+ }
+
+ _onWindowReady({ window }) {
+ if (this.isObserving) {
+ this._startListeners([window]);
+ }
+ }
+
+ _onWindowDestroyed({ window }) {
+ if (this.isObserving) {
+ this._stopListeners([window]);
+ }
+ }
+
+ _startListeners(windows) {
+ // To be implemented by sub-classes.
+ }
+
+ _stopListeners(windows) {
+ // To be implemented by sub-classes.
+ }
+
+ /**
+ * To be called by sub-classes when something has been observed
+ */
+ notifyCallback(...args) {
+ this.isObserving && this.callback && this.callback.apply(null, args);
+ }
+}
+
+/**
+ * The LayouChangesObserver will observe reflows as soon as it is started.
+ * Some devtools actors may cause reflows and it may be wanted to "hide" these
+ * reflows from the LayouChangesObserver consumers.
+ * If this is the case, such actors should require this module and use this
+ * global function to turn the ignore mode on and off temporarily.
+ *
+ * Note that if a node is provided, it will be used to force a sync reflow to
+ * make sure all reflows which occurred before switching the mode on or off are
+ * either observed or ignored depending on the current mode.
+ *
+ * @param {Boolean} ignore
+ * @param {DOMNode} syncReflowNode The node to use to force a sync reflow
+ */
+var gIgnoreLayoutChanges = false;
+exports.setIgnoreLayoutChanges = function (ignore, syncReflowNode) {
+ if (syncReflowNode) {
+ let forceSyncReflow = syncReflowNode.offsetWidth; // eslint-disable-line
+ }
+ gIgnoreLayoutChanges = ignore;
+};
+
+class LayoutChangesObserver extends EventEmitter {
+ /**
+ * The LayoutChangesObserver class is instantiated only once per given tab
+ * and is used to track reflows and dom and style changes in that tab.
+ * The LayoutActor uses this class to send reflow events to its clients.
+ *
+ * This class isn't exported on the module because it shouldn't be instantiated
+ * to avoid creating several instances per tabs.
+ * Use `getLayoutChangesObserver(targetActor)`
+ * and `releaseLayoutChangesObserver(targetActor)`
+ * which are exported to get and release instances.
+ *
+ * The observer loops every EVENT_BATCHING_DELAY ms and checks if layout changes
+ * have happened since the last loop iteration. If there are, it sends the
+ * corresponding events:
+ *
+ * - "reflows", with an array of all the reflows that occured,
+ * - "resizes", with an array of all the resizes that occured,
+ *
+ * @param {WindowGlobalTargetActor} targetActor
+ */
+ constructor(targetActor) {
+ super();
+
+ this.targetActor = targetActor;
+
+ this._startEventLoop = this._startEventLoop.bind(this);
+ this._onReflow = this._onReflow.bind(this);
+ this._onResize = this._onResize.bind(this);
+
+ // Creating the various observers we're going to need
+ // For now, just the reflow observer, but later we can add markupMutation,
+ // styleSheetChanges and styleRuleChanges
+ this.reflowObserver = new ReflowObserver(this.targetActor, this._onReflow);
+ this.resizeObserver = new WindowResizeObserver(
+ this.targetActor,
+ this._onResize
+ );
+ }
+
+ /**
+ * How long does this observer waits before emitting batched events.
+ * The lower the value, the more event packets will be sent to clients,
+ * potentially impacting performance.
+ * The higher the value, the more time we'll wait, this is better for
+ * performance but has an effect on how soon changes are shown in the toolbox.
+ */
+ EVENT_BATCHING_DELAY = 300;
+
+ /**
+ * Destroying this instance of LayoutChangesObserver will stop the batched
+ * events from being sent.
+ */
+ destroy() {
+ this.isObserving = false;
+
+ this.reflowObserver.destroy();
+ this.reflows = null;
+
+ this.resizeObserver.destroy();
+ this.hasResized = false;
+
+ this.targetActor = null;
+ }
+
+ start() {
+ if (this.isObserving) {
+ return;
+ }
+ this.isObserving = true;
+
+ this.reflows = [];
+ this.hasResized = false;
+
+ this._startEventLoop();
+
+ this.reflowObserver.start();
+ this.resizeObserver.start();
+ }
+
+ stop() {
+ if (!this.isObserving) {
+ return;
+ }
+ this.isObserving = false;
+
+ this._stopEventLoop();
+
+ this.reflows = [];
+ this.hasResized = false;
+
+ this.reflowObserver.stop();
+ this.resizeObserver.stop();
+ }
+
+ /**
+ * Start the event loop, which regularly checks if there are any observer
+ * events to be sent as batched events
+ * Calls itself in a loop.
+ */
+ _startEventLoop() {
+ // Avoid emitting events if the targetActor has been detached (may happen
+ // during shutdown)
+ if (!this.targetActor || this.targetActor.isDestroyed()) {
+ return;
+ }
+
+ // Send any reflows we have
+ if (this.reflows && this.reflows.length) {
+ this.emit("reflows", this.reflows);
+ this.reflows = [];
+ }
+
+ // Send any resizes we have
+ if (this.hasResized) {
+ this.emit("resize");
+ this.hasResized = false;
+ }
+
+ this.eventLoopTimer = this._setTimeout(
+ this._startEventLoop,
+ this.EVENT_BATCHING_DELAY
+ );
+ }
+
+ _stopEventLoop() {
+ this._clearTimeout(this.eventLoopTimer);
+ }
+
+ // Exposing set/clearTimeout here to let tests override them if needed
+ _setTimeout(cb, ms) {
+ return setTimeout(cb, ms);
+ }
+ _clearTimeout(t) {
+ return clearTimeout(t);
+ }
+
+ /**
+ * Executed whenever a reflow is observed. Only stacks the reflow in the
+ * reflows array.
+ * The EVENT_BATCHING_DELAY loop will take care of it later.
+ * @param {Number} start When the reflow started
+ * @param {Number} end When the reflow ended
+ * @param {Boolean} isInterruptible
+ */
+ _onReflow(start, end, isInterruptible) {
+ if (gIgnoreLayoutChanges) {
+ return;
+ }
+
+ // XXX: when/if bug 997092 gets fixed, we will be able to know which
+ // elements have been reflowed, which would be a nice thing to add here.
+ this.reflows.push({
+ start,
+ end,
+ isInterruptible,
+ });
+ }
+
+ /**
+ * Executed whenever a resize is observed. Only store a flag saying that a
+ * resize occured.
+ * The EVENT_BATCHING_DELAY loop will take care of it later.
+ */
+ _onResize() {
+ if (gIgnoreLayoutChanges) {
+ return;
+ }
+
+ this.hasResized = true;
+ }
+}
+exports.LayoutChangesObserver = LayoutChangesObserver;
+
+/**
+ * Get a LayoutChangesObserver instance for a given window. This function makes
+ * sure there is only one instance per window.
+ * @param {WindowGlobalTargetActor} targetActor
+ * @return {LayoutChangesObserver}
+ */
+var observedWindows = new Map();
+function getLayoutChangesObserver(targetActor) {
+ const observerData = observedWindows.get(targetActor);
+ if (observerData) {
+ observerData.refCounting++;
+ return observerData.observer;
+ }
+
+ const obs = new LayoutChangesObserver(targetActor);
+ observedWindows.set(targetActor, {
+ observer: obs,
+ // counting references allows to stop the observer when no targetActor owns an
+ // instance.
+ refCounting: 1,
+ });
+ obs.start();
+ return obs;
+}
+exports.getLayoutChangesObserver = getLayoutChangesObserver;
+
+/**
+ * Release a LayoutChangesObserver instance that was retrieved by
+ * getLayoutChangesObserver. This is required to ensure the targetActor reference
+ * is removed and the observer is eventually stopped and destroyed.
+ * @param {WindowGlobalTargetActor} targetActor
+ */
+function releaseLayoutChangesObserver(targetActor) {
+ const observerData = observedWindows.get(targetActor);
+ if (!observerData) {
+ return;
+ }
+
+ observerData.refCounting--;
+ if (!observerData.refCounting) {
+ observerData.observer.destroy();
+ observedWindows.delete(targetActor);
+ }
+}
+exports.releaseLayoutChangesObserver = releaseLayoutChangesObserver;
+
+/**
+ * Reports any reflow that occurs in the targetActor's docshells.
+ * @extends Observable
+ * @param {WindowGlobalTargetActor} targetActor
+ * @param {Function} callback Executed everytime a reflow occurs
+ */
+class ReflowObserver extends Observable {
+ constructor(targetActor, callback) {
+ super(targetActor, callback);
+ }
+
+ _startListeners(windows) {
+ for (const window of windows) {
+ window.docShell.addWeakReflowObserver(this);
+ }
+ }
+
+ _stopListeners(windows) {
+ for (const window of windows) {
+ try {
+ window.docShell.removeWeakReflowObserver(this);
+ } catch (e) {
+ // Corner cases where a global has already been freed may happen, in
+ // which case, no need to remove the observer.
+ }
+ }
+ }
+
+ reflow(start, end) {
+ this.notifyCallback(start, end, false);
+ }
+
+ reflowInterruptible(start, end) {
+ this.notifyCallback(start, end, true);
+ }
+}
+
+ReflowObserver.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIReflowObserver",
+ "nsISupportsWeakReference",
+]);
+
+/**
+ * Reports window resize events on the targetActor's windows.
+ * @extends Observable
+ * @param {WindowGlobalTargetActor} targetActor
+ * @param {Function} callback Executed everytime a resize occurs
+ */
+class WindowResizeObserver extends Observable {
+ constructor(targetActor, callback) {
+ super(targetActor, callback);
+
+ this.onNavigate = this.onNavigate.bind(this);
+ this.onResize = this.onResize.bind(this);
+
+ this.targetActor.on("navigate", this.onNavigate);
+ }
+
+ _startListeners() {
+ this.listenerTarget.addEventListener("resize", this.onResize);
+ }
+
+ _stopListeners() {
+ this.listenerTarget.removeEventListener("resize", this.onResize);
+ }
+
+ onNavigate() {
+ if (this.isObserving) {
+ this._stopListeners();
+ this._startListeners();
+ }
+ }
+
+ onResize() {
+ this.notifyCallback();
+ }
+
+ destroy() {
+ if (this.targetActor) {
+ this.targetActor.off("navigate", this.onNavigate);
+ }
+ super.destroy();
+ }
+
+ get listenerTarget() {
+ // For the rootActor, return its window.
+ if (this.targetActor.isRootActor) {
+ return this.targetActor.window;
+ }
+
+ // Otherwise, get the targetActor's chromeEventHandler.
+ return this.targetActor.chromeEventHandler;
+ }
+}
diff --git a/devtools/server/actors/resources/console-messages.js b/devtools/server/actors/resources/console-messages.js
new file mode 100644
index 0000000000..a643546692
--- /dev/null
+++ b/devtools/server/actors/resources/console-messages.js
@@ -0,0 +1,302 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { CONSOLE_MESSAGE },
+} = require("devtools/server/actors/resources/index");
+const Targets = require("devtools/server/actors/targets/index");
+
+const consoleAPIListenerModule = isWorker
+ ? "devtools/server/actors/webconsole/worker-listeners"
+ : "devtools/server/actors/webconsole/listeners/console-api";
+const { ConsoleAPIListener } = require(consoleAPIListenerModule);
+
+const { isArray } = require("devtools/server/actors/object/utils");
+
+const {
+ makeDebuggeeValue,
+ createValueGripForTarget,
+} = require("devtools/server/actors/object/utils");
+
+const {
+ getActorIdForInternalSourceId,
+} = require("devtools/server/actors/utils/dbg-source");
+
+const {
+ isSupportedByConsoleTable,
+} = require("devtools/shared/webconsole/messages");
+
+/**
+ * Start watching for all console messages related to a given Target Actor.
+ * This will notify about existing console messages, but also the one created in future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe console messages
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+class ConsoleMessageWatcher {
+ async watch(targetActor, { onAvailable }) {
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+
+ // Bug 1642297: Maybe we could merge ConsoleAPI Listener into this module?
+ const onConsoleAPICall = message => {
+ onAvailable([
+ {
+ resourceType: CONSOLE_MESSAGE,
+ message: prepareConsoleMessageForRemote(targetActor, message),
+ },
+ ]);
+ };
+
+ const isTargetActorContentProcess =
+ targetActor.targetType === Targets.TYPES.PROCESS;
+
+ // Only consider messages from a given window for all FRAME targets (this includes
+ // WebExt and ParentProcess which inherits from WindowGlobalTargetActor)
+ // But ParentProcess should be ignored as we want all messages emitted directly from
+ // that process (window and window-less).
+ // To do that we pass a null window and ConsoleAPIListener will catch everything.
+ // And also ignore WebExtension as we will filter out only by addonId, which is
+ // passed via consoleAPIListenerOptions. WebExtension may have multiple windows/documents
+ // but all of them will be flagged with the same addon ID.
+ const messagesShouldMatchWindow =
+ targetActor.targetType === Targets.TYPES.FRAME &&
+ targetActor.typeName != "parentProcessTarget" &&
+ targetActor.typeName != "webExtensionTarget";
+ const window = messagesShouldMatchWindow ? targetActor.window : null;
+
+ // If we should match messages for a given window but for some reason, targetActor.window
+ // did not return a window, bail out. Otherwise we wouldn't have anything to match against
+ // and would consume all the messages, which could lead to issue (e.g. infinite loop,
+ // see Bug 1828026).
+ if (messagesShouldMatchWindow && !window) {
+ return;
+ }
+
+ const listener = new ConsoleAPIListener(window, onConsoleAPICall, {
+ excludeMessagesBoundToWindow: isTargetActorContentProcess,
+ matchExactWindow: targetActor.ignoreSubFrames,
+ ...(targetActor.consoleAPIListenerOptions || {}),
+ });
+ this.listener = listener;
+ listener.init();
+
+ // It can happen that the targetActor does not have a window reference (e.g. in worker
+ // thread, targetActor exposes a workerGlobal property)
+ const winStartTime =
+ targetActor.window?.performance?.timing?.navigationStart || 0;
+
+ const cachedMessages = listener.getCachedMessages(!targetActor.isRootActor);
+ const messages = [];
+ // Filter out messages that came from a ServiceWorker but happened
+ // before the page was requested.
+ for (const message of cachedMessages) {
+ if (
+ message.innerID === "ServiceWorker" &&
+ winStartTime > message.timeStamp
+ ) {
+ continue;
+ }
+ messages.push({
+ resourceType: CONSOLE_MESSAGE,
+ message: prepareConsoleMessageForRemote(targetActor, message),
+ });
+ }
+ onAvailable(messages);
+ }
+
+ /**
+ * Stop watching for console messages.
+ */
+ destroy() {
+ if (this.listener) {
+ this.listener.destroy();
+ this.listener = null;
+ }
+ this.targetActor = null;
+ this.onAvailable = null;
+ }
+
+ /**
+ * Spawn some custom console messages.
+ * This is used for example for log points and JS tracing.
+ *
+ * @param Array<Object> messages
+ * A list of fake nsIConsoleMessage, which looks like the one being generated by
+ * the platform API.
+ */
+ emitMessages(messages) {
+ if (!this.listener) {
+ throw new Error("This target actor isn't listening to console messages");
+ }
+ this.onAvailable(
+ messages.map(message => {
+ if (!message.timeStamp) {
+ throw new Error("timeStamp property is mandatory");
+ }
+
+ return {
+ resourceType: CONSOLE_MESSAGE,
+ message: prepareConsoleMessageForRemote(this.targetActor, message),
+ };
+ })
+ );
+ }
+}
+
+module.exports = ConsoleMessageWatcher;
+
+/**
+ * Return the properties needed to display the appropriate table for a given
+ * console.table call.
+ * This function does a little more than creating an ObjectActor for the first
+ * parameter of the message. When layout out the console table in the output, we want
+ * to be able to look into sub-properties so the table can have a different layout (
+ * for arrays of arrays, objects with objects properties, arrays of objects, …).
+ * So here we need to retrieve the properties of the first parameter, and also all the
+ * sub-properties we might need.
+ *
+ * @param {TargetActor} targetActor: The Target Actor from which this object originates.
+ * @param {Object} result: The console.table message.
+ * @returns {Object} An object containing the properties of the first argument of the
+ * console.table call.
+ */
+function getConsoleTableMessageItems(targetActor, result) {
+ const [tableItemGrip] = result.arguments;
+ const dataType = tableItemGrip.class;
+ const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType);
+ const ignoreNonIndexedProperties = isArray(tableItemGrip);
+
+ const tableItemActor = targetActor.getActorByID(tableItemGrip.actor);
+ if (!tableItemActor) {
+ return null;
+ }
+
+ // Retrieve the properties (or entries for Set/Map) of the console table first arg.
+ const iterator = needEntries
+ ? tableItemActor.enumEntries()
+ : tableItemActor.enumProperties({
+ ignoreNonIndexedProperties,
+ });
+ const { ownProperties } = iterator.all();
+
+ // The iterator returns a descriptor for each property, wherein the value could be
+ // in one of those sub-property.
+ const descriptorKeys = ["safeGetterValues", "getterValue", "value"];
+
+ Object.values(ownProperties).forEach(desc => {
+ if (typeof desc !== "undefined") {
+ descriptorKeys.forEach(key => {
+ if (desc && desc.hasOwnProperty(key)) {
+ const grip = desc[key];
+
+ // We need to load sub-properties as well to render the table in a nice way.
+ const actor = grip && targetActor.getActorByID(grip.actor);
+ if (actor) {
+ const res = actor
+ .enumProperties({
+ ignoreNonIndexedProperties: isArray(grip),
+ })
+ .all();
+ if (res?.ownProperties) {
+ desc[key].ownProperties = res.ownProperties;
+ }
+ }
+ }
+ });
+ }
+ });
+
+ return ownProperties;
+}
+
+/**
+ * Prepare a message from the console API to be sent to the remote Web Console
+ * instance.
+ *
+ * @param TargetActor targetActor
+ * The related target actor
+ * @param object message
+ * The original message received from the console storage listener.
+ * @return object
+ * The object that can be sent to the remote client.
+ */
+function prepareConsoleMessageForRemote(targetActor, message) {
+ const result = {
+ arguments: message.arguments
+ ? message.arguments.map(obj => {
+ const dbgObj = makeDebuggeeValue(targetActor, obj);
+ return createValueGripForTarget(targetActor, dbgObj);
+ })
+ : [],
+ columnNumber: message.columnNumber,
+ filename: message.filename,
+ level: message.level,
+ lineNumber: message.lineNumber,
+ // messages emitted from Console.sys.mjs don't have a microSecondTimeStamp property
+ timeStamp: message.microSecondTimeStamp
+ ? message.microSecondTimeStamp / 1000
+ : message.timeStamp || ChromeUtils.dateNow(),
+ sourceId: getActorIdForInternalSourceId(targetActor, message.sourceId),
+ innerWindowID: message.innerID,
+ };
+
+ // This can be a hot path when loading lots of messages, and it only make sense to
+ // include the following properties in the message when they have a meaningful value.
+ // Otherwise we simply don't include them so we save cycles in JSActor communication.
+ if (message.chromeContext) {
+ result.chromeContext = message.chromeContext;
+ }
+
+ if (message.counter) {
+ result.counter = message.counter;
+ }
+ if (message.private) {
+ result.private = message.private;
+ }
+ if (message.prefix) {
+ result.prefix = message.prefix;
+ }
+
+ if (message.stacktrace) {
+ result.stacktrace = message.stacktrace.map(frame => {
+ return {
+ ...frame,
+ sourceId: getActorIdForInternalSourceId(targetActor, frame.sourceId),
+ };
+ });
+ }
+
+ if (message.styles && message.styles.length) {
+ result.styles = message.styles.map(string => {
+ return createValueGripForTarget(targetActor, string);
+ });
+ }
+
+ if (message.timer) {
+ result.timer = message.timer;
+ }
+
+ if (message.level === "table") {
+ if (result && isSupportedByConsoleTable(result.arguments)) {
+ const tableItems = getConsoleTableMessageItems(targetActor, result);
+ if (tableItems) {
+ result.arguments[0].ownProperties = tableItems;
+ result.arguments[0].preview = null;
+
+ // Only return the 2 first params.
+ result.arguments = result.arguments.slice(0, 2);
+ }
+ }
+ // NOTE: See transformConsoleAPICallResource for not-supported case.
+ }
+
+ return result;
+}
diff --git a/devtools/server/actors/resources/css-changes.js b/devtools/server/actors/resources/css-changes.js
new file mode 100644
index 0000000000..e86503be87
--- /dev/null
+++ b/devtools/server/actors/resources/css-changes.js
@@ -0,0 +1,42 @@
+/* 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: { CSS_CHANGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+const TrackChangeEmitter = require("resource://devtools/server/actors/utils/track-change-emitter.js");
+
+/**
+ * Start watching for all css changes related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe css changes.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+class CSSChangeWatcher {
+ constructor() {
+ this.onTrackChange = this.onTrackChange.bind(this);
+ }
+
+ async watch(targetActor, { onAvailable }) {
+ this.onAvailable = onAvailable;
+ TrackChangeEmitter.on("track-change", this.onTrackChange);
+ }
+
+ onTrackChange(change) {
+ change.resourceType = CSS_CHANGE;
+ this.onAvailable([change]);
+ }
+
+ destroy() {
+ TrackChangeEmitter.off("track-change", this.onTrackChange);
+ }
+}
+
+module.exports = CSSChangeWatcher;
diff --git a/devtools/server/actors/resources/css-messages.js b/devtools/server/actors/resources/css-messages.js
new file mode 100644
index 0000000000..0bc6e7ac8a
--- /dev/null
+++ b/devtools/server/actors/resources/css-messages.js
@@ -0,0 +1,202 @@
+/* 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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const {
+ createStringGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+const {
+ getActorIdForInternalSourceId,
+} = require("resource://devtools/server/actors/utils/dbg-source.js");
+const {
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["getStyleSheetText"],
+ "resource://devtools/server/actors/utils/stylesheet-utils.js",
+ true
+);
+
+const {
+ TYPES: { CSS_MESSAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js");
+
+class CSSMessageWatcher extends nsIConsoleListenerWatcher {
+ /**
+ * Start watching for all CSS messages related to a given Target Actor.
+ * This will notify about existing messages, but also the one created in future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe messages
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ super.watch(targetActor, { onAvailable });
+
+ // Calling ensureCSSErrorReportingEnabled will make the server parse the stylesheets to
+ // retrieve the warnings if the docShell wasn't already watching for CSS messages.
+ await this.#ensureCSSErrorReportingEnabled(targetActor);
+ }
+
+ /**
+ * Returns true if the message is considered a CSS message, and as a result, should
+ * be sent to the client.
+ *
+ * @param {nsIConsoleMessage|nsIScriptError} message
+ */
+ shouldHandleMessage(targetActor, message) {
+ // The listener we use can be called either with a nsIConsoleMessage or as nsIScriptError.
+ // In this file, we want to ignore anything but nsIScriptError.
+ if (
+ // We only care about CSS Parser nsIScriptError
+ !(message instanceof Ci.nsIScriptError) ||
+ message.category !== MESSAGE_CATEGORY.CSS_PARSER
+ ) {
+ return false;
+ }
+
+ // Filter specific to CONTENT PROCESS targets
+ // Process targets listen for everything but messages from private windows.
+ if (this.isProcessTarget(targetActor)) {
+ return !message.isFromPrivateWindow;
+ }
+
+ if (!message.innerWindowID) {
+ return false;
+ }
+
+ const ids = targetActor.windows.map(window =>
+ WebConsoleUtils.getInnerWindowId(window)
+ );
+ return ids.includes(message.innerWindowID);
+ }
+
+ /**
+ * Prepare an nsIScriptError to be sent to the client.
+ *
+ * @param nsIScriptError error
+ * The page error we need to send to the client.
+ * @return object
+ * The object you can send to the remote client.
+ */
+ buildResource(targetActor, error) {
+ const stack = this.prepareStackForRemote(targetActor, error.stack);
+ let lineText = error.sourceLine;
+ if (
+ lineText &&
+ lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ ) {
+ lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH);
+ }
+
+ const notesArray = this.prepareNotesForRemote(targetActor, error.notes);
+
+ // If there is no location information in the error but we have a stack,
+ // fill in the location with the first frame on the stack.
+ let { sourceName, sourceId, lineNumber, columnNumber } = error;
+ if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) {
+ sourceName = stack[0].filename;
+ sourceId = stack[0].sourceId;
+ lineNumber = stack[0].lineNumber;
+ columnNumber = stack[0].columnNumber;
+ }
+
+ const pageError = {
+ errorMessage: createStringGrip(targetActor, error.errorMessage),
+ sourceName,
+ sourceId: getActorIdForInternalSourceId(targetActor, sourceId),
+ lineText,
+ lineNumber,
+ columnNumber,
+ category: error.category,
+ innerWindowID: error.innerWindowID,
+ timeStamp: error.microSecondTimeStamp / 1000,
+ warning: !!(error.flags & error.warningFlag),
+ error: !(error.flags & (error.warningFlag | error.infoFlag)),
+ info: !!(error.flags & error.infoFlag),
+ private: error.isFromPrivateWindow,
+ stacktrace: stack,
+ notes: notesArray,
+ chromeContext: error.isFromChromeContext,
+ isForwardedFromContentProcess: error.isForwardedFromContentProcess,
+ };
+
+ return {
+ pageError,
+ resourceType: CSS_MESSAGE,
+ cssSelectors: error.cssSelectors,
+ };
+ }
+
+ /**
+ * Ensure that CSS error reporting is enabled for the provided target actor.
+ *
+ * @param {TargetActor} targetActor
+ * The target actor for which CSS Error Reporting should be enabled.
+ * @return {Promise} Promise that resolves when cssErrorReportingEnabled was
+ * set in all the docShells owned by the provided target, and existing
+ * stylesheets have been re-parsed if needed.
+ */
+ async #ensureCSSErrorReportingEnabled(targetActor) {
+ const docShells = targetActor.docShells;
+ if (!docShells) {
+ // If the target actor does not expose a docShells getter (ie is not an
+ // instance of WindowGlobalTargetActor), nothing to do here.
+ return;
+ }
+
+ const promises = docShells.map(async docShell => {
+ if (docShell.cssErrorReportingEnabled) {
+ // CSS Error Reporting already enabled here, nothing to do.
+ return;
+ }
+
+ try {
+ docShell.cssErrorReportingEnabled = true;
+ } catch (e) {
+ return;
+ }
+
+ // After enabling CSS Error Reporting, reparse existing stylesheets to
+ // detect potential CSS errors.
+
+ // Ensure docShell.document is available.
+ docShell.QueryInterface(Ci.nsIWebNavigation);
+ // We don't really want to reparse UA sheets and such, but want to do
+ // Shadow DOM / XBL.
+ const sheets = InspectorUtils.getAllStyleSheets(
+ docShell.document,
+ /* documentOnly = */ true
+ );
+ for (const sheet of sheets) {
+ if (InspectorUtils.hasRulesModifiedByCSSOM(sheet)) {
+ continue;
+ }
+
+ try {
+ // Reparse the sheet so that we see the existing errors.
+ const text = await getStyleSheetText(sheet);
+ InspectorUtils.parseStyleSheet(sheet, text, /* aUpdate = */ false);
+ } catch (e) {
+ console.error("Error while parsing stylesheet");
+ }
+ }
+ });
+
+ await Promise.all(promises);
+ }
+}
+module.exports = CSSMessageWatcher;
diff --git a/devtools/server/actors/resources/css-registered-properties.js b/devtools/server/actors/resources/css-registered-properties.js
new file mode 100644
index 0000000000..7ac2871a11
--- /dev/null
+++ b/devtools/server/actors/resources/css-registered-properties.js
@@ -0,0 +1,270 @@
+/* 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: { CSS_REGISTERED_PROPERTIES },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+/**
+ * @typedef InspectorCSSPropertyDefinition (see InspectorUtils.webidl)
+ * @type {object}
+ * @property {string} name
+ * @property {string} syntax
+ * @property {boolean} inherits
+ * @property {string} initialValue
+ * @property {boolean} fromJS - true if property was registered via CSS.registerProperty
+ */
+
+class CSSRegisteredPropertiesWatcher {
+ #abortController;
+ #onAvailable;
+ #onUpdated;
+ #onDestroyed;
+ #registeredPropertiesCache = new Map();
+ #styleSheetsManager;
+ #targetActor;
+
+ /**
+ * Start watching for all registered CSS properties (@property/CSS.registerProperty)
+ * related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe css changes.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * - onUpdated: mandatory function
+ * - onDestroyed: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable, onUpdated, onDestroyed }) {
+ this.#targetActor = targetActor;
+ this.#onAvailable = onAvailable;
+ this.#onUpdated = onUpdated;
+ this.#onDestroyed = onDestroyed;
+
+ // Notify about existing properties
+ const registeredProperties = this.#getRegisteredProperties();
+ for (const registeredProperty of registeredProperties) {
+ this.#registeredPropertiesCache.set(
+ registeredProperty.name,
+ registeredProperty
+ );
+ }
+
+ this.#notifyResourcesAvailable(registeredProperties);
+
+ // Listen for new properties being registered via CSS.registerProperty
+ this.#abortController = new AbortController();
+ const { signal } = this.#abortController;
+ this.#targetActor.chromeEventHandler.addEventListener(
+ "csscustompropertyregistered",
+ this.#onCssCustomPropertyRegistered,
+ { capture: true, signal }
+ );
+
+ // Watch for stylesheets being added/modified or destroyed, but don't handle existing
+ // stylesheets, as we already have the existing properties from this.#getRegisteredProperties.
+ this.#styleSheetsManager = targetActor.getStyleSheetsManager();
+ await this.#styleSheetsManager.watch({
+ onAvailable: this.#refreshCacheAndNotify,
+ onUpdated: this.#refreshCacheAndNotify,
+ onDestroyed: this.#refreshCacheAndNotify,
+ ignoreExisting: true,
+ });
+ }
+
+ /**
+ * Get all the registered properties for the target actor document.
+ *
+ * @returns Array<InspectorCSSPropertyDefinition>
+ */
+ #getRegisteredProperties() {
+ return InspectorUtils.getCSSRegisteredProperties(
+ this.#targetActor.window.document
+ );
+ }
+
+ /**
+ * Compute a resourceId from a given property definition
+ *
+ * @param {InspectorCSSPropertyDefinition} propertyDefinition
+ * @returns string
+ */
+ #getRegisteredPropertyResourceId(propertyDefinition) {
+ return `${this.#targetActor.actorID}:css-registered-property:${
+ propertyDefinition.name
+ }`;
+ }
+
+ /**
+ * Called when a stylesheet is added, removed or modified.
+ * This will retrieve the registered properties at this very moment, and notify
+ * about new, updated and removed registered properties.
+ */
+ #refreshCacheAndNotify = async () => {
+ const registeredProperties = this.#getRegisteredProperties();
+ const existingPropertiesNames = new Set(
+ this.#registeredPropertiesCache.keys()
+ );
+
+ const added = [];
+ const updated = [];
+ const removed = [];
+
+ for (const registeredProperty of registeredProperties) {
+ // If the property isn't in the cache already, this is a new one.
+ if (!this.#registeredPropertiesCache.has(registeredProperty.name)) {
+ added.push(registeredProperty);
+ this.#registeredPropertiesCache.set(
+ registeredProperty.name,
+ registeredProperty
+ );
+ continue;
+ }
+
+ // Removing existing property from the Set so we can then later get the properties
+ // that don't exist anymore.
+ existingPropertiesNames.delete(registeredProperty.name);
+
+ // The property already existed, so we need to check if its definition was modified
+ const cachedRegisteredProperty = this.#registeredPropertiesCache.get(
+ registeredProperty.name
+ );
+
+ const resourceUpdates = {};
+ let wasUpdated = false;
+ if (registeredProperty.syntax !== cachedRegisteredProperty.syntax) {
+ resourceUpdates.syntax = registeredProperty.syntax;
+ wasUpdated = true;
+ }
+ if (registeredProperty.inherits !== cachedRegisteredProperty.inherits) {
+ resourceUpdates.inherits = registeredProperty.inherits;
+ wasUpdated = true;
+ }
+ if (
+ registeredProperty.initialValue !==
+ cachedRegisteredProperty.initialValue
+ ) {
+ resourceUpdates.initialValue = registeredProperty.initialValue;
+ wasUpdated = true;
+ }
+
+ if (wasUpdated === true) {
+ updated.push({
+ registeredProperty,
+ resourceUpdates,
+ });
+ this.#registeredPropertiesCache.set(
+ registeredProperty.name,
+ registeredProperty
+ );
+ }
+ }
+
+ // If there are items left in the Set, it means they weren't processed in the for loop
+ // before, meaning they don't exist anymore.
+ for (const registeredPropertyName of existingPropertiesNames) {
+ removed.push(this.#registeredPropertiesCache.get(registeredPropertyName));
+ this.#registeredPropertiesCache.delete(registeredPropertyName);
+ }
+
+ this.#notifyResourcesAvailable(added);
+ this.#notifyResourcesUpdated(updated);
+ this.#notifyResourcesDestroyed(removed);
+ };
+
+ /**
+ * csscustompropertyregistered event listener callback (fired when a property
+ * is registered via CSS.registerProperty).
+ *
+ * @param {CSSCustomPropertyRegisteredEvent} event
+ */
+ #onCssCustomPropertyRegistered = event => {
+ // Ignore event if property was registered from a global different from the target global.
+ if (
+ this.#targetActor.ignoreSubFrames &&
+ event.target.ownerGlobal !== this.#targetActor.window
+ ) {
+ return;
+ }
+
+ const registeredProperty = event.propertyDefinition;
+ this.#registeredPropertiesCache.set(
+ registeredProperty.name,
+ registeredProperty
+ );
+ this.#notifyResourcesAvailable([registeredProperty]);
+ };
+
+ /**
+ * @param {Array<InspectorCSSPropertyDefinition>} registeredProperties
+ */
+ #notifyResourcesAvailable = registeredProperties => {
+ if (!registeredProperties.length) {
+ return;
+ }
+
+ for (const registeredProperty of registeredProperties) {
+ registeredProperty.resourceId =
+ this.#getRegisteredPropertyResourceId(registeredProperty);
+ registeredProperty.resourceType = CSS_REGISTERED_PROPERTIES;
+ }
+ this.#onAvailable(registeredProperties);
+ };
+
+ /**
+ * @param {Array<Object>} updates: Array of update object, which have the following properties:
+ * - {InspectorCSSPropertyDefinition} registeredProperty: The property definition
+ * of the updated property
+ * - {Object} resourceUpdates: An object containing all the fields that are
+ * modified for the registered property.
+ */
+ #notifyResourcesUpdated = updates => {
+ if (!updates.length) {
+ return;
+ }
+
+ for (const update of updates) {
+ update.resourceId = this.#getRegisteredPropertyResourceId(
+ update.registeredProperty
+ );
+ update.resourceType = CSS_REGISTERED_PROPERTIES;
+ // We don't need to send the property definition
+ delete update.registeredProperty;
+ }
+
+ this.#onUpdated(updates);
+ };
+
+ /**
+ * @param {Array<InspectorCSSPropertyDefinition>} registeredProperties
+ */
+ #notifyResourcesDestroyed = registeredProperties => {
+ if (!registeredProperties.length) {
+ return;
+ }
+
+ this.#onDestroyed(
+ registeredProperties.map(registeredProperty => ({
+ resourceType: CSS_REGISTERED_PROPERTIES,
+ resourceId: this.#getRegisteredPropertyResourceId(registeredProperty),
+ }))
+ );
+ };
+
+ destroy() {
+ this.#styleSheetsManager.unwatch({
+ onAvailable: this.#refreshCacheAndNotify,
+ onUpdated: this.#refreshCacheAndNotify,
+ onDestroyed: this.#refreshCacheAndNotify,
+ });
+
+ this.#abortController.abort();
+ }
+}
+
+module.exports = CSSRegisteredPropertiesWatcher;
diff --git a/devtools/server/actors/resources/document-event.js b/devtools/server/actors/resources/document-event.js
new file mode 100644
index 0000000000..bd6667b2b5
--- /dev/null
+++ b/devtools/server/actors/resources/document-event.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";
+
+const {
+ TYPES: { DOCUMENT_EVENT },
+} = require("resource://devtools/server/actors/resources/index.js");
+const {
+ DocumentEventsListener,
+} = require("resource://devtools/server/actors/webconsole/listeners/document-events.js");
+
+class DocumentEventWatcher {
+ #abortController = new AbortController();
+ /**
+ * Start watching for all document event related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe document event
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ if (isWorker) {
+ return;
+ }
+
+ const onDocumentEvent = (
+ name,
+ {
+ time,
+ // This will be `true` when the user selected a document in the frame picker tool,
+ // in the toolbox toolbar.
+ isFrameSwitching,
+ // This is only passed for dom-complete event
+ hasNativeConsoleAPI,
+ // This is only passed for will-navigate event
+ newURI,
+ } = {}
+ ) => {
+ // Ignore will-navigate as that's managed by parent-process-document-event.js.
+ // Except frame switching, when selecting an iframe document via the dropdown menu,
+ // this is handled by the target actor in the content process and the parent process
+ // doesn't know about it.
+ if (name == "will-navigate" && !isFrameSwitching) {
+ return;
+ }
+ onAvailable([
+ {
+ resourceType: DOCUMENT_EVENT,
+ name,
+ time,
+ isFrameSwitching,
+ // only send `title` on dom interactive (once the HTML was parsed) so we don't
+ // make the payload bigger for events where we either don't have a title yet,
+ // or where we already had a chance to get the title.
+ title: name === "dom-interactive" ? targetActor.title : undefined,
+ // only send `url` on dom loading and dom-interactive so we don't make the
+ // payload bigger for other events
+ url:
+ name === "dom-loading" || name === "dom-interactive"
+ ? targetActor.url
+ : undefined,
+ // only send `newURI` on will navigate so we don't make the payload bigger for
+ // other events
+ newURI: name === "will-navigate" ? newURI : null,
+ // only send `hasNativeConsoleAPI` on dom complete so we don't make the payload bigger for
+ // other events
+ hasNativeConsoleAPI:
+ name == "dom-complete" ? hasNativeConsoleAPI : null,
+ },
+ ]);
+ };
+
+ this.listener = new DocumentEventsListener(targetActor);
+
+ this.listener.on(
+ "will-navigate",
+ data => onDocumentEvent("will-navigate", data),
+ { signal: this.#abortController.signal }
+ );
+ this.listener.on(
+ "dom-loading",
+ data => onDocumentEvent("dom-loading", data),
+ { signal: this.#abortController.signal }
+ );
+ this.listener.on(
+ "dom-interactive",
+ data => onDocumentEvent("dom-interactive", data),
+ { signal: this.#abortController.signal }
+ );
+ this.listener.on(
+ "dom-complete",
+ data => onDocumentEvent("dom-complete", data),
+ { signal: this.#abortController.signal }
+ );
+
+ this.listener.listen();
+ }
+
+ destroy() {
+ this.#abortController.abort();
+ if (this.listener) {
+ this.listener.destroy();
+ }
+ }
+}
+
+module.exports = DocumentEventWatcher;
diff --git a/devtools/server/actors/resources/error-messages.js b/devtools/server/actors/resources/error-messages.js
new file mode 100644
index 0000000000..7628d7fd6d
--- /dev/null
+++ b/devtools/server/actors/resources/error-messages.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";
+
+const nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const ErrorDocs = require("resource://devtools/server/actors/errordocs.js");
+const {
+ createStringGrip,
+ makeDebuggeeValue,
+ createValueGripForTarget,
+} = require("resource://devtools/server/actors/object/utils.js");
+const {
+ getActorIdForInternalSourceId,
+} = require("resource://devtools/server/actors/utils/dbg-source.js");
+const {
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+const {
+ TYPES: { ERROR_MESSAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js");
+
+const PLATFORM_SPECIFIC_CATEGORIES = [
+ "XPConnect JavaScript",
+ "component javascript",
+ "chrome javascript",
+ "chrome registration",
+];
+
+class ErrorMessageWatcher extends nsIConsoleListenerWatcher {
+ shouldHandleMessage(targetActor, message, isCachedMessage = false) {
+ // The listener we use can be called either with a nsIConsoleMessage or a nsIScriptError.
+ // In this file, we only want to handle nsIScriptError.
+ if (
+ // We only care about nsIScriptError
+ !(message instanceof Ci.nsIScriptError) ||
+ !this.isCategoryAllowed(targetActor, message.category) ||
+ // Block any error that was triggered by eager evaluation
+ message.sourceName === "debugger eager eval code"
+ ) {
+ return false;
+ }
+
+ // Filter specific to CONTENT PROCESS targets
+ if (this.isProcessTarget(targetActor)) {
+ // Don't want to display cached messages from private windows.
+ const isCachedFromPrivateWindow =
+ isCachedMessage && message.isFromPrivateWindow;
+ if (isCachedFromPrivateWindow) {
+ return false;
+ }
+
+ // `ContentChild` forwards all errors to the parent process (via IPC) all errors up
+ // the parent process and sets a `isForwardedFromContentProcess` property on them.
+ // Ignore these forwarded messages as the original ones will be logged either in a
+ // content process target (if window-less message) or frame target (if related to a window)
+ if (message.isForwardedFromContentProcess) {
+ return false;
+ }
+
+ // Ignore all messages related to a given window for content process targets
+ // These messages will be handled by Watchers instantiated for the related frame targets
+ if (
+ targetActor.targetType == Targets.TYPES.PROCESS &&
+ message.innerWindowID
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (!message.innerWindowID) {
+ return false;
+ }
+
+ const ids = targetActor.windows.map(window =>
+ WebConsoleUtils.getInnerWindowId(window)
+ );
+ return ids.includes(message.innerWindowID);
+ }
+
+ /**
+ * Check if the given message category is allowed to be tracked or not.
+ * We ignore chrome-originating errors as we only care about content.
+ *
+ * @param string category
+ * The message category you want to check.
+ * @return boolean
+ * True if the category is allowed to be logged, false otherwise.
+ */
+ isCategoryAllowed(targetActor, category) {
+ // CSS Parser errors will be handled by the CSSMessageWatcher.
+ if (!category || category === MESSAGE_CATEGORY.CSS_PARSER) {
+ return false;
+ }
+
+ // We listen for everything on Process targets
+ if (this.isProcessTarget(targetActor)) {
+ return true;
+ }
+
+ // Don't restrict any categories in the Browser Toolbox/Browser Console
+ if (targetActor.sessionContext.type == "all") {
+ return true;
+ }
+
+ // For non-process targets in other toolboxes, we filter-out platform-specific errors.
+ return !PLATFORM_SPECIFIC_CATEGORIES.includes(category);
+ }
+
+ /**
+ * Prepare an nsIScriptError to be sent to the client.
+ *
+ * @param nsIScriptError error
+ * The page error we need to send to the client.
+ * @return object
+ * The object you can send to the remote client.
+ */
+ buildResource(targetActor, error) {
+ const stack = this.prepareStackForRemote(targetActor, error.stack);
+ let lineText = error.sourceLine;
+ if (
+ lineText &&
+ lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ ) {
+ lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH);
+ }
+
+ const notesArray = this.prepareNotesForRemote(targetActor, error.notes);
+
+ // If there is no location information in the error but we have a stack,
+ // fill in the location with the first frame on the stack.
+ let { sourceName, sourceId, lineNumber, columnNumber } = error;
+ if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) {
+ sourceName = stack[0].filename;
+ sourceId = stack[0].sourceId;
+ lineNumber = stack[0].lineNumber;
+ columnNumber = stack[0].columnNumber;
+ }
+
+ const pageError = {
+ errorMessage: createStringGrip(targetActor, error.errorMessage),
+ errorMessageName: error.errorMessageName,
+ exceptionDocURL: ErrorDocs.GetURL(error),
+ sourceName,
+ sourceId: getActorIdForInternalSourceId(targetActor, sourceId),
+ lineText,
+ lineNumber,
+ columnNumber,
+ category: error.category,
+ innerWindowID: error.innerWindowID,
+ timeStamp: error.microSecondTimeStamp / 1000,
+ warning: !!(error.flags & error.warningFlag),
+ error: !(error.flags & (error.warningFlag | error.infoFlag)),
+ info: !!(error.flags & error.infoFlag),
+ private: error.isFromPrivateWindow,
+ stacktrace: stack,
+ notes: notesArray,
+ chromeContext: error.isFromChromeContext,
+ isPromiseRejection: error.isPromiseRejection,
+ isForwardedFromContentProcess: error.isForwardedFromContentProcess,
+ };
+
+ // If the pageError does have an exception object, we want to return the grip for it,
+ // but only if we do manage to get the grip, as we're checking the property on the
+ // client to render things differently.
+ if (error.hasException) {
+ try {
+ const obj = makeDebuggeeValue(targetActor, error.exception);
+ if (obj?.class !== "DeadObject") {
+ pageError.exception = createValueGripForTarget(targetActor, obj);
+ pageError.hasException = true;
+ }
+ } catch (e) {}
+ }
+
+ return {
+ pageError,
+ resourceType: ERROR_MESSAGE,
+ };
+ }
+}
+module.exports = ErrorMessageWatcher;
diff --git a/devtools/server/actors/resources/extensions-backgroundscript-status.js b/devtools/server/actors/resources/extensions-backgroundscript-status.js
new file mode 100644
index 0000000000..08f51a23f5
--- /dev/null
+++ b/devtools/server/actors/resources/extensions-backgroundscript-status.js
@@ -0,0 +1,68 @@
+/* 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: { EXTENSIONS_BGSCRIPT_STATUS },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+class ExtensionsBackgroundScriptStatusWatcher {
+ /**
+ * Start watching for the status updates related to a background
+ * scripts extension context (either an event page or a background
+ * service worker).
+ *
+ * This is used in about:debugging to update the background script
+ * row updated visible in Extensions details cards (only for extensions
+ * with a non persistent background script defined in the manifest)
+ * when the background contex is terminated on idle or started back
+ * to handle a persistent WebExtensions API event.
+ *
+ * @param RootActor rootActor
+ * The root actor in the parent process from which we should
+ * observe root resources.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(rootActor, { onAvailable }) {
+ this.rootActor = rootActor;
+ this.onAvailable = onAvailable;
+
+ Services.obs.addObserver(this, "extension:background-script-status");
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "extension:background-script-status": {
+ const { addonId, isRunning } = subject.wrappedJSObject;
+ this.onBackgroundScriptStatus(addonId, isRunning);
+ break;
+ }
+ }
+ }
+
+ onBackgroundScriptStatus(addonId, isRunning) {
+ this.onAvailable([
+ {
+ resourceType: EXTENSIONS_BGSCRIPT_STATUS,
+ payload: {
+ addonId,
+ isRunning,
+ },
+ },
+ ]);
+ }
+
+ destroy() {
+ if (this.onAvailable) {
+ this.onAvailable = null;
+ Services.obs.removeObserver(this, "extension:background-script-status");
+ }
+ }
+}
+
+module.exports = ExtensionsBackgroundScriptStatusWatcher;
diff --git a/devtools/server/actors/resources/index.js b/devtools/server/actors/resources/index.js
new file mode 100644
index 0000000000..e2857502ad
--- /dev/null
+++ b/devtools/server/actors/resources/index.js
@@ -0,0 +1,471 @@
+/* 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 Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const TYPES = {
+ CONSOLE_MESSAGE: "console-message",
+ CSS_CHANGE: "css-change",
+ CSS_MESSAGE: "css-message",
+ CSS_REGISTERED_PROPERTIES: "css-registered-properties",
+ DOCUMENT_EVENT: "document-event",
+ ERROR_MESSAGE: "error-message",
+ LAST_PRIVATE_CONTEXT_EXIT: "last-private-context-exit",
+ NETWORK_EVENT: "network-event",
+ NETWORK_EVENT_STACKTRACE: "network-event-stacktrace",
+ PLATFORM_MESSAGE: "platform-message",
+ REFLOW: "reflow",
+ SERVER_SENT_EVENT: "server-sent-event",
+ SOURCE: "source",
+ STYLESHEET: "stylesheet",
+ THREAD_STATE: "thread-state",
+ JSTRACER_TRACE: "jstracer-trace",
+ JSTRACER_STATE: "jstracer-state",
+ WEBSOCKET: "websocket",
+
+ // storage types
+ CACHE_STORAGE: "Cache",
+ COOKIE: "cookies",
+ EXTENSION_STORAGE: "extension-storage",
+ INDEXED_DB: "indexed-db",
+ LOCAL_STORAGE: "local-storage",
+ SESSION_STORAGE: "session-storage",
+
+ // root types
+ EXTENSIONS_BGSCRIPT_STATUS: "extensions-backgroundscript-status",
+};
+exports.TYPES = TYPES;
+
+// Helper dictionaries, which will contain data specific to each resource type.
+// - `path` is the absolute path to the module defining the Resource Watcher class.
+//
+// Also see the attributes added by `augmentResourceDictionary` for each type:
+// - `watchers` is a weak map which will store Resource Watchers
+// (i.e. devtools/server/actors/resources/ class instances)
+// keyed by target actor -or- watcher actor.
+// - `WatcherClass` is a shortcut to the Resource Watcher module.
+// Each module exports a Resource Watcher class.
+//
+// These are several dictionaries, which depend how the resource watcher classes are instantiated.
+
+// Frame target resources are spawned via a BrowsingContext Target Actor.
+// Their watcher class receives a target actor as first argument.
+// They are instantiated for each observed BrowsingContext, from the content process where it runs.
+// They are meant to observe all resources related to a given Browsing Context.
+const FrameTargetResources = augmentResourceDictionary({
+ [TYPES.CACHE_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-cache",
+ },
+ [TYPES.CONSOLE_MESSAGE]: {
+ path: "devtools/server/actors/resources/console-messages",
+ },
+ [TYPES.CSS_CHANGE]: {
+ path: "devtools/server/actors/resources/css-changes",
+ },
+ [TYPES.CSS_MESSAGE]: {
+ path: "devtools/server/actors/resources/css-messages",
+ },
+ [TYPES.CSS_REGISTERED_PROPERTIES]: {
+ path: "devtools/server/actors/resources/css-registered-properties",
+ },
+ [TYPES.DOCUMENT_EVENT]: {
+ path: "devtools/server/actors/resources/document-event",
+ },
+ [TYPES.ERROR_MESSAGE]: {
+ path: "devtools/server/actors/resources/error-messages",
+ },
+ [TYPES.JSTRACER_STATE]: {
+ path: "devtools/server/actors/resources/jstracer-state",
+ },
+ [TYPES.JSTRACER_TRACE]: {
+ path: "devtools/server/actors/resources/jstracer-trace",
+ },
+ [TYPES.LOCAL_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-local-storage",
+ },
+ [TYPES.PLATFORM_MESSAGE]: {
+ path: "devtools/server/actors/resources/platform-messages",
+ },
+ [TYPES.SESSION_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-session-storage",
+ },
+ [TYPES.STYLESHEET]: {
+ path: "devtools/server/actors/resources/stylesheets",
+ },
+ [TYPES.NETWORK_EVENT]: {
+ path: "devtools/server/actors/resources/network-events-content",
+ },
+ [TYPES.NETWORK_EVENT_STACKTRACE]: {
+ path: "devtools/server/actors/resources/network-events-stacktraces",
+ },
+ [TYPES.REFLOW]: {
+ path: "devtools/server/actors/resources/reflow",
+ },
+ [TYPES.SOURCE]: {
+ path: "devtools/server/actors/resources/sources",
+ },
+ [TYPES.THREAD_STATE]: {
+ path: "devtools/server/actors/resources/thread-states",
+ },
+ [TYPES.SERVER_SENT_EVENT]: {
+ path: "devtools/server/actors/resources/server-sent-events",
+ },
+ [TYPES.WEBSOCKET]: {
+ path: "devtools/server/actors/resources/websockets",
+ },
+});
+
+// Process target resources are spawned via a Process Target Actor.
+// Their watcher class receives a process target actor as first argument.
+// They are instantiated for each observed Process (parent and all content processes).
+// They are meant to observe all resources related to a given process.
+const ProcessTargetResources = augmentResourceDictionary({
+ [TYPES.CONSOLE_MESSAGE]: {
+ path: "devtools/server/actors/resources/console-messages",
+ },
+ [TYPES.JSTRACER_TRACE]: {
+ path: "devtools/server/actors/resources/jstracer-trace",
+ },
+ [TYPES.JSTRACER_STATE]: {
+ path: "devtools/server/actors/resources/jstracer-state",
+ },
+ [TYPES.ERROR_MESSAGE]: {
+ path: "devtools/server/actors/resources/error-messages",
+ },
+ [TYPES.PLATFORM_MESSAGE]: {
+ path: "devtools/server/actors/resources/platform-messages",
+ },
+ [TYPES.SOURCE]: {
+ path: "devtools/server/actors/resources/sources",
+ },
+ [TYPES.THREAD_STATE]: {
+ path: "devtools/server/actors/resources/thread-states",
+ },
+});
+
+// Worker target resources are spawned via a Worker Target Actor.
+// Their watcher class receives a worker target actor as first argument.
+// They are instantiated for each observed worker, from the worker thread.
+// They are meant to observe all resources related to a given worker.
+//
+// We'll only support a few resource types in Workers (console-message, source,
+// thread state, …) as error and platform messages are not supported since we need access
+// to Ci, which isn't available in worker context.
+// Errors are emitted from the content process main thread so the user would still get them.
+const WorkerTargetResources = augmentResourceDictionary({
+ [TYPES.CONSOLE_MESSAGE]: {
+ path: "devtools/server/actors/resources/console-messages",
+ },
+ [TYPES.JSTRACER_TRACE]: {
+ path: "devtools/server/actors/resources/jstracer-trace",
+ },
+ [TYPES.JSTRACER_STATE]: {
+ path: "devtools/server/actors/resources/jstracer-state",
+ },
+ [TYPES.SOURCE]: {
+ path: "devtools/server/actors/resources/sources",
+ },
+ [TYPES.THREAD_STATE]: {
+ path: "devtools/server/actors/resources/thread-states",
+ },
+});
+
+// Parent process resources are spawned via the Watcher Actor.
+// Their watcher class receives the watcher actor as first argument.
+// They are instantiated once per watcher from the parent process.
+// They are meant to observe all resources related to a given context designated by the Watcher (and its sessionContext)
+// they should be observed from the parent process.
+const ParentProcessResources = augmentResourceDictionary({
+ [TYPES.NETWORK_EVENT]: {
+ path: "devtools/server/actors/resources/network-events",
+ },
+ [TYPES.COOKIE]: {
+ path: "devtools/server/actors/resources/storage-cookie",
+ },
+ [TYPES.EXTENSION_STORAGE]: {
+ path: "devtools/server/actors/resources/storage-extension",
+ },
+ [TYPES.INDEXED_DB]: {
+ path: "devtools/server/actors/resources/storage-indexed-db",
+ },
+ [TYPES.DOCUMENT_EVENT]: {
+ path: "devtools/server/actors/resources/parent-process-document-event",
+ },
+ [TYPES.LAST_PRIVATE_CONTEXT_EXIT]: {
+ path: "devtools/server/actors/resources/last-private-context-exit",
+ },
+});
+
+// Root resources are spawned via the Root Actor.
+// Their watcher class receives the root actor as first argument.
+// They are instantiated only once from the parent process.
+// They are meant to observe anything easily observable from the parent process
+// that isn't related to any particular context/target.
+// This is especially useful when you need to observe something without having to instantiate a Watcher actor.
+const RootResources = augmentResourceDictionary({
+ [TYPES.EXTENSIONS_BGSCRIPT_STATUS]: {
+ path: "devtools/server/actors/resources/extensions-backgroundscript-status",
+ },
+});
+exports.RootResources = RootResources;
+
+function augmentResourceDictionary(dict) {
+ for (const resource of Object.values(dict)) {
+ resource.watchers = new WeakMap();
+
+ loader.lazyRequireGetter(resource, "WatcherClass", resource.path);
+ }
+ return dict;
+}
+
+/**
+ * For a given actor, return the related dictionary defined just before,
+ * that contains info about how to listen for a given resource type, from a given actor.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource.
+ */
+function getResourceTypeDictionary(rootOrWatcherOrTargetActor) {
+ const { typeName } = rootOrWatcherOrTargetActor;
+ if (typeName == "root") {
+ return RootResources;
+ }
+ if (typeName == "watcher") {
+ return ParentProcessResources;
+ }
+ const { targetType } = rootOrWatcherOrTargetActor;
+ return getResourceTypeDictionaryForTargetType(targetType);
+}
+
+/**
+ * For a targetType, return the related dictionary.
+ *
+ * @param String targetType
+ * A targetType string (See Targets.TYPES)
+ */
+function getResourceTypeDictionaryForTargetType(targetType) {
+ switch (targetType) {
+ case Targets.TYPES.FRAME:
+ return FrameTargetResources;
+ case Targets.TYPES.PROCESS:
+ return ProcessTargetResources;
+ case Targets.TYPES.WORKER:
+ return WorkerTargetResources;
+ case Targets.TYPES.SERVICE_WORKER:
+ return WorkerTargetResources;
+ default:
+ throw new Error(`Unsupported target actor typeName '${targetType}'`);
+ }
+}
+
+/**
+ * For a given actor, return the object stored in one of the previous dictionary
+ * that contains info about how to listen for a given resource type, from a given actor.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource.
+ * @param String resourceType
+ * The resource type to be observed.
+ */
+function getResourceTypeEntry(rootOrWatcherOrTargetActor, resourceType) {
+ const dict = getResourceTypeDictionary(rootOrWatcherOrTargetActor);
+ if (!(resourceType in dict)) {
+ throw new Error(
+ `Unsupported resource type '${resourceType}' for ${rootOrWatcherOrTargetActor.typeName}`
+ );
+ }
+ return dict[resourceType];
+}
+
+/**
+ * Start watching for a new list of resource types.
+ * This will also emit all already existing resources before resolving.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * Either a RootActor or WatcherActor or a TargetActor which can be listening to a resource:
+ * * RootActor will be used for resources observed from the parent process and aren't related to any particular
+ * context/descriptor. They can be observed right away when connecting to the RDP server
+ * without instantiating any actor other than the root actor.
+ * * WatcherActor will be used for resources listened from the parent process.
+ * * TargetActor will be used for resources listened from the content process.
+ * This actor:
+ * - defines what context to observe (browsing context, process, worker, ...)
+ * Via browsingContextID, windows, docShells attributes for the target actor.
+ * Via the `sessionContext` object for the watcher actor.
+ * (only for Watcher and Target actors. Root actor is context-less.)
+ * - exposes `notifyResources` method to be notified about all the resources updates
+ * This method will receive two arguments:
+ * - {String} updateType, which can be "available", "updated", or "destroyed"
+ * - {Array<Object>} resources, which will be the list of resource's forms
+ * or special update object for "updated" scenario.
+ * @param Array<String> resourceTypes
+ * List of all type of resource to listen to.
+ */
+async function watchResources(rootOrWatcherOrTargetActor, resourceTypes) {
+ // If we are given a target actor, filter out the resource types supported by the target.
+ // When using sharedData to pass types between processes, we are passing them for all target types.
+ const { targetType } = rootOrWatcherOrTargetActor;
+ // Only target actors usecase will have a target type.
+ // For Root and Watcher we process the `resourceTypes` list unfiltered.
+ if (targetType) {
+ resourceTypes = getResourceTypesForTargetType(resourceTypes, targetType);
+ }
+ const promises = [];
+ for (const resourceType of resourceTypes) {
+ const { watchers, WatcherClass } = getResourceTypeEntry(
+ rootOrWatcherOrTargetActor,
+ resourceType
+ );
+
+ // Ignore resources we're already listening to
+ if (watchers.has(rootOrWatcherOrTargetActor)) {
+ continue;
+ }
+
+ // Don't watch for console messages from the worker target if worker messages are still
+ // being cloned to the main process, otherwise we'll get duplicated messages in the
+ // console output (See Bug 1778852).
+ if (
+ resourceType == TYPES.CONSOLE_MESSAGE &&
+ rootOrWatcherOrTargetActor.workerConsoleApiMessagesDispatchedToMainThread
+ ) {
+ continue;
+ }
+
+ const watcher = new WatcherClass();
+ promises.push(
+ watcher.watch(rootOrWatcherOrTargetActor, {
+ onAvailable: rootOrWatcherOrTargetActor.notifyResources.bind(
+ rootOrWatcherOrTargetActor,
+ "available"
+ ),
+ onUpdated: rootOrWatcherOrTargetActor.notifyResources.bind(
+ rootOrWatcherOrTargetActor,
+ "updated"
+ ),
+ onDestroyed: rootOrWatcherOrTargetActor.notifyResources.bind(
+ rootOrWatcherOrTargetActor,
+ "destroyed"
+ ),
+ })
+ );
+ watchers.set(rootOrWatcherOrTargetActor, watcher);
+ }
+ await Promise.all(promises);
+}
+exports.watchResources = watchResources;
+
+function getParentProcessResourceTypes(resourceTypes) {
+ return resourceTypes.filter(resourceType => {
+ return resourceType in ParentProcessResources;
+ });
+}
+exports.getParentProcessResourceTypes = getParentProcessResourceTypes;
+
+function getResourceTypesForTargetType(resourceTypes, targetType) {
+ const resourceDictionnary =
+ getResourceTypeDictionaryForTargetType(targetType);
+ return resourceTypes.filter(resourceType => {
+ return resourceType in resourceDictionnary;
+ });
+}
+exports.getResourceTypesForTargetType = getResourceTypesForTargetType;
+
+function hasResourceTypesForTargets(resourceTypes) {
+ return resourceTypes.some(resourceType => {
+ return resourceType in FrameTargetResources;
+ });
+}
+exports.hasResourceTypesForTargets = hasResourceTypesForTargets;
+
+/**
+ * Stop watching for a list of resource types.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * The related actor, already passed to watchResources.
+ * @param Array<String> resourceTypes
+ * List of all type of resource to stop listening to.
+ */
+function unwatchResources(rootOrWatcherOrTargetActor, resourceTypes) {
+ for (const resourceType of resourceTypes) {
+ // Pull all info about this resource type from `Resources` global object
+ const { watchers } = getResourceTypeEntry(
+ rootOrWatcherOrTargetActor,
+ resourceType
+ );
+
+ const watcher = watchers.get(rootOrWatcherOrTargetActor);
+ if (watcher) {
+ watcher.destroy();
+ watchers.delete(rootOrWatcherOrTargetActor);
+ }
+ }
+}
+exports.unwatchResources = unwatchResources;
+
+/**
+ * Clear resources for a list of resource types.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * The related actor, already passed to watchResources.
+ * @param Array<String> resourceTypes
+ * List of all type of resource to clear.
+ */
+function clearResources(rootOrWatcherOrTargetActor, resourceTypes) {
+ for (const resourceType of resourceTypes) {
+ const { watchers } = getResourceTypeEntry(
+ rootOrWatcherOrTargetActor,
+ resourceType
+ );
+
+ const watcher = watchers.get(rootOrWatcherOrTargetActor);
+ if (watcher && typeof watcher.clear == "function") {
+ watcher.clear();
+ }
+ }
+}
+
+exports.clearResources = clearResources;
+
+/**
+ * Stop watching for all watched resources on a given actor.
+ *
+ * @param Actor rootOrWatcherOrTargetActor
+ * The related actor, already passed to watchResources.
+ */
+function unwatchAllResources(rootOrWatcherOrTargetActor) {
+ for (const { watchers } of Object.values(
+ getResourceTypeDictionary(rootOrWatcherOrTargetActor)
+ )) {
+ const watcher = watchers.get(rootOrWatcherOrTargetActor);
+ if (watcher) {
+ watcher.destroy();
+ watchers.delete(rootOrWatcherOrTargetActor);
+ }
+ }
+}
+exports.unwatchAllResources = unwatchAllResources;
+
+/**
+ * If we are watching for the given resource type,
+ * return the current ResourceWatcher instance used by this target actor
+ * in order to observe this resource type.
+ *
+ * @param Actor watcherOrTargetActor
+ * Either a WatcherActor or a TargetActor which can be listening to a resource.
+ * WatcherActor will be used for resources listened from the parent process,
+ * and TargetActor will be used for resources listened from the content process.
+ * @param String resourceType
+ * The resource type to query
+ * @return ResourceWatcher
+ * The resource watcher instance, defined in devtools/server/actors/resources/
+ */
+function getResourceWatcher(watcherOrTargetActor, resourceType) {
+ const { watchers } = getResourceTypeEntry(watcherOrTargetActor, resourceType);
+
+ return watchers.get(watcherOrTargetActor);
+}
+exports.getResourceWatcher = getResourceWatcher;
diff --git a/devtools/server/actors/resources/jstracer-state.js b/devtools/server/actors/resources/jstracer-state.js
new file mode 100644
index 0000000000..74491a6ced
--- /dev/null
+++ b/devtools/server/actors/resources/jstracer-state.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";
+
+const {
+ TYPES: { JSTRACER_STATE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+// Bug 1827382, as this module can be used from the worker thread,
+// the following JSM may be loaded by the worker loader until
+// we have proper support for ESM from workers.
+const {
+ addTracingListener,
+ removeTracingListener,
+} = require("resource://devtools/server/tracer/tracer.jsm");
+
+const { LOG_METHODS } = require("resource://devtools/server/actors/tracer.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+class TracingStateWatcher {
+ /**
+ * Start watching for tracing state changes for a given target actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ // Bug 1874204: tracer doesn't support tracing content process from the browser toolbox just yet
+ if (targetActor.targetType == Targets.TYPES.PROCESS) {
+ return;
+ }
+
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+
+ this.tracingListener = {
+ onTracingToggled: this.onTracingToggled.bind(this),
+ };
+ addTracingListener(this.tracingListener);
+ }
+
+ /**
+ * Stop watching for tracing state
+ */
+ destroy() {
+ if (!this.tracingListener) {
+ return;
+ }
+ removeTracingListener(this.tracingListener);
+ }
+
+ /**
+ * Be notified by the underlying JavaScriptTracer class
+ * in case it stops by itself, instead of being stopped when the Actor's stopTracing
+ * method is called by the user.
+ *
+ * @param {Boolean} enabled
+ * True if the tracer starts tracing, false it it stops.
+ * @param {String} reason
+ * Optional string to justify why the tracer stopped.
+ */
+ onTracingToggled(enabled, reason) {
+ const tracerActor = this.targetActor.getTargetScopedActor("tracer");
+ const logMethod = tracerActor?.getLogMethod();
+
+ // JavascriptTracer only supports recording once in the same process/thread.
+ // If we open another DevTools, on the same process, we would receive notification
+ // about a JavascriptTracer controlled by another toolbox's tracer actor.
+ // Ignore them as our current tracer actor didn't start tracing.
+ if (!logMethod) {
+ return;
+ }
+
+ this.onAvailable([
+ {
+ resourceType: JSTRACER_STATE,
+ enabled,
+ logMethod,
+ profile:
+ logMethod == LOG_METHODS.PROFILER && !enabled
+ ? tracerActor.getProfile()
+ : undefined,
+ timeStamp: ChromeUtils.dateNow(),
+ reason,
+ },
+ ]);
+ }
+}
+
+module.exports = TracingStateWatcher;
diff --git a/devtools/server/actors/resources/jstracer-trace.js b/devtools/server/actors/resources/jstracer-trace.js
new file mode 100644
index 0000000000..0a614fd6a9
--- /dev/null
+++ b/devtools/server/actors/resources/jstracer-trace.js
@@ -0,0 +1,43 @@
+/* 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 JSTraceWatcher {
+ /**
+ * Start watching for traces for a given target actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ this.#onAvailable = onAvailable;
+ }
+
+ #onAvailable;
+
+ /**
+ * Stop watching for traces
+ */
+ destroy() {
+ // The traces are being emitted by the TracerActor via `emitTraces` method,
+ // we start and stop recording and emitting tracer from this actor.
+ // Watching for JSTRACER_TRACE only allows receiving these trace events.
+ }
+
+ /**
+ * Emit a JSTRACER_TRACE resource.
+ *
+ * This is being called by the Tracer Actor.
+ */
+ emitTraces(traces) {
+ this.#onAvailable(traces);
+ }
+}
+
+module.exports = JSTraceWatcher;
diff --git a/devtools/server/actors/resources/last-private-context-exit.js b/devtools/server/actors/resources/last-private-context-exit.js
new file mode 100644
index 0000000000..ec9ee6b91d
--- /dev/null
+++ b/devtools/server/actors/resources/last-private-context-exit.js
@@ -0,0 +1,46 @@
+/* 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: { LAST_PRIVATE_CONTEXT_EXIT },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+class LastPrivateContextExitWatcher {
+ #onAvailable;
+
+ /**
+ * Start watching for all times where we close a private browsing top level window.
+ * Meaning we should clear the console for all logs generated from these private browsing contexts.
+ *
+ * @param WatcherActor watcherActor
+ * The watcher actor in the parent process from which we should
+ * observe these events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(watcherActor, { onAvailable }) {
+ this.#onAvailable = onAvailable;
+ Services.obs.addObserver(this, "last-pb-context-exited");
+ }
+
+ observe(subject, topic) {
+ if (topic === "last-pb-context-exited") {
+ this.#onAvailable([
+ {
+ resourceType: LAST_PRIVATE_CONTEXT_EXIT,
+ },
+ ]);
+ }
+ }
+
+ destroy() {
+ Services.obs.removeObserver(this, "last-pb-context-exited");
+ }
+}
+
+module.exports = LastPrivateContextExitWatcher;
diff --git a/devtools/server/actors/resources/moz.build b/devtools/server/actors/resources/moz.build
new file mode 100644
index 0000000000..b3d2656b94
--- /dev/null
+++ b/devtools/server/actors/resources/moz.build
@@ -0,0 +1,44 @@
+# -*- 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 += [
+ "storage",
+ "utils",
+]
+
+DevToolsModules(
+ "console-messages.js",
+ "css-changes.js",
+ "css-messages.js",
+ "css-registered-properties.js",
+ "document-event.js",
+ "error-messages.js",
+ "extensions-backgroundscript-status.js",
+ "index.js",
+ "jstracer-state.js",
+ "jstracer-trace.js",
+ "last-private-context-exit.js",
+ "network-events-content.js",
+ "network-events-stacktraces.js",
+ "network-events.js",
+ "parent-process-document-event.js",
+ "platform-messages.js",
+ "reflow.js",
+ "server-sent-events.js",
+ "sources.js",
+ "storage-cache.js",
+ "storage-cookie.js",
+ "storage-extension.js",
+ "storage-indexed-db.js",
+ "storage-local-storage.js",
+ "storage-session-storage.js",
+ "stylesheets.js",
+ "thread-states.js",
+ "websockets.js",
+)
+
+with Files("*-messages.js"):
+ BUG_COMPONENT = ("DevTools", "Console")
diff --git a/devtools/server/actors/resources/network-events-content.js b/devtools/server/actors/resources/network-events-content.js
new file mode 100644
index 0000000000..5135583fab
--- /dev/null
+++ b/devtools/server/actors/resources/network-events-content.js
@@ -0,0 +1,267 @@
+/* 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,
+ "NetworkEventActor",
+ "resource://devtools/server/actors/network-monitor/network-event-actor.js",
+ true
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+/**
+ * Handles network events from the content process
+ * This currently only handles events for requests (js/css) blocked by CSP.
+ */
+class NetworkEventContentWatcher {
+ /**
+ * Start watching for all network events related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor in the content process from which we should
+ * observe network events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ * - onUpdated: optional function
+ * This would be called multiple times for each resource.
+ */
+ async watch(targetActor, { onAvailable, onUpdated }) {
+ // Map from channelId to network event objects.
+ this.networkEvents = new Map();
+
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+ this.onUpdated = onUpdated;
+
+ this.httpFailedOpeningRequest = this.httpFailedOpeningRequest.bind(this);
+ this.httpOnImageCacheResponse = this.httpOnImageCacheResponse.bind(this);
+
+ Services.obs.addObserver(
+ this.httpFailedOpeningRequest,
+ "http-on-failed-opening-request"
+ );
+
+ Services.obs.addObserver(
+ this.httpOnImageCacheResponse,
+ "http-on-image-cache-response"
+ );
+ }
+ /**
+ * Allows clearing of network events
+ */
+ clear() {
+ this.networkEvents.clear();
+ }
+
+ httpFailedOpeningRequest(subject, topic) {
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+
+ // Ignore preload requests to avoid duplicity request entries in
+ // the Network panel. If a preload fails (for whatever reason)
+ // then the platform kicks off another 'real' request.
+ if (lazy.NetworkUtils.isPreloadRequest(channel)) {
+ return;
+ }
+
+ if (
+ !lazy.NetworkUtils.matchRequest(channel, {
+ targetActor: this.targetActor,
+ })
+ ) {
+ return;
+ }
+
+ this.onNetworkEventAvailable(channel, {
+ networkEventOptions: {
+ blockedReason: channel.loadInfo.requestBlockingReason,
+ },
+ });
+ }
+
+ httpOnImageCacheResponse(subject, topic) {
+ if (
+ topic != "http-on-image-cache-response" ||
+ !(subject instanceof Ci.nsIHttpChannel)
+ ) {
+ return;
+ }
+
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+
+ if (
+ !lazy.NetworkUtils.matchRequest(channel, {
+ targetActor: this.targetActor,
+ })
+ ) {
+ return;
+ }
+
+ // Only one network request should be created per URI for images from the cache
+ const hasURI = Array.from(this.networkEvents.values()).some(
+ networkEvent => networkEvent.uri === channel.URI.spec
+ );
+
+ if (hasURI) {
+ return;
+ }
+
+ this.onNetworkEventAvailable(channel, {
+ networkEventOptions: { fromCache: true },
+ });
+ }
+
+ onNetworkEventAvailable(channel, { networkEventOptions }) {
+ const actor = new NetworkEventActor(
+ this.targetActor.conn,
+ this.targetActor.sessionContext,
+ {
+ onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this),
+ onNetworkEventDestroy: this.onNetworkEventDestroyed.bind(this),
+ },
+ networkEventOptions,
+ channel
+ );
+ this.targetActor.manage(actor);
+
+ const resource = actor.asResource();
+
+ const networkEvent = {
+ browsingContextID: resource.browsingContextID,
+ innerWindowId: resource.innerWindowId,
+ resourceId: resource.resourceId,
+ resourceType: resource.resourceType,
+ receivedUpdates: [],
+ resourceUpdates: {
+ // Requests already come with request cookies and headers, so those
+ // should always be considered as available. But the client still
+ // heavily relies on those `Available` flags to fetch additional data,
+ // so it is better to keep them for consistency.
+ requestCookiesAvailable: true,
+ requestHeadersAvailable: true,
+ },
+ uri: channel.URI.spec,
+ };
+ this.networkEvents.set(resource.resourceId, networkEvent);
+
+ this.onAvailable([resource]);
+ const isBlocked = !!resource.blockedReason;
+ if (isBlocked) {
+ this._emitUpdate(networkEvent);
+ } else {
+ actor.addResponseStart({ channel, fromCache: true });
+ actor.addEventTimings(
+ 0 /* totalTime */,
+ {} /* timings */,
+ {} /* offsets */
+ );
+ actor.addServerTimings({});
+ actor.addResponseContent(
+ {
+ mimeType: channel.contentType,
+ size: channel.contentLength,
+ text: "",
+ transferredSize: 0,
+ },
+ {}
+ );
+ }
+ }
+
+ onNetworkEventUpdate(updateResource) {
+ const networkEvent = this.networkEvents.get(updateResource.resourceId);
+
+ if (!networkEvent) {
+ return;
+ }
+
+ const { resourceUpdates, receivedUpdates } = networkEvent;
+
+ switch (updateResource.updateType) {
+ case "responseStart":
+ // For cached image requests channel.responseStatus is set to 200 as
+ // expected. However responseStatusText is empty. In this case fallback
+ // to the expected statusText "OK".
+ let statusText = updateResource.statusText;
+ if (!statusText && updateResource.status === "200") {
+ statusText = "OK";
+ }
+ resourceUpdates.httpVersion = updateResource.httpVersion;
+ resourceUpdates.status = updateResource.status;
+ resourceUpdates.statusText = statusText;
+ resourceUpdates.remoteAddress = updateResource.remoteAddress;
+ resourceUpdates.remotePort = updateResource.remotePort;
+ resourceUpdates.waitingTime = updateResource.waitingTime;
+
+ resourceUpdates.responseHeadersAvailable = true;
+ resourceUpdates.responseCookiesAvailable = true;
+ break;
+ case "responseContent":
+ resourceUpdates.contentSize = updateResource.contentSize;
+ resourceUpdates.mimeType = updateResource.mimeType;
+ resourceUpdates.transferredSize = updateResource.transferredSize;
+ break;
+ case "eventTimings":
+ resourceUpdates.totalTime = updateResource.totalTime;
+ break;
+ }
+
+ resourceUpdates[`${updateResource.updateType}Available`] = true;
+ receivedUpdates.push(updateResource.updateType);
+
+ // Here we explicitly call all three `add` helpers on each network event
+ // actor so in theory we could check only the last one to be called, ie
+ // responseContent.
+ const isComplete =
+ receivedUpdates.includes("responseStart") &&
+ receivedUpdates.includes("responseContent") &&
+ receivedUpdates.includes("eventTimings");
+
+ if (isComplete) {
+ this._emitUpdate(networkEvent);
+ }
+ }
+
+ _emitUpdate(networkEvent) {
+ this.onUpdated([
+ {
+ resourceType: networkEvent.resourceType,
+ resourceId: networkEvent.resourceId,
+ resourceUpdates: networkEvent.resourceUpdates,
+ browsingContextID: networkEvent.browsingContextID,
+ innerWindowId: networkEvent.innerWindowId,
+ },
+ ]);
+ }
+
+ onNetworkEventDestroyed(channelId) {
+ if (this.networkEvents.has(channelId)) {
+ this.networkEvents.delete(channelId);
+ }
+ }
+
+ destroy() {
+ this.clear();
+ Services.obs.removeObserver(
+ this.httpFailedOpeningRequest,
+ "http-on-failed-opening-request"
+ );
+
+ Services.obs.removeObserver(
+ this.httpOnImageCacheResponse,
+ "http-on-image-cache-response"
+ );
+ }
+}
+
+module.exports = NetworkEventContentWatcher;
diff --git a/devtools/server/actors/resources/network-events-stacktraces.js b/devtools/server/actors/resources/network-events-stacktraces.js
new file mode 100644
index 0000000000..a458278680
--- /dev/null
+++ b/devtools/server/actors/resources/network-events-stacktraces.js
@@ -0,0 +1,214 @@
+/* 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: { NETWORK_EVENT_STACKTRACE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "ChannelEventSinkFactory",
+ "resource://devtools/server/actors/network-monitor/channel-event-sink.js",
+ true
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+class NetworkEventStackTracesWatcher {
+ /**
+ * Start watching for all network event's stack traces related to a given Target actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe the strack traces
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ this.stacktraces = new Map();
+ this.onStackTraceAvailable = onAvailable;
+ this.targetActor = targetActor;
+
+ Services.obs.addObserver(this, "http-on-opening-request");
+ Services.obs.addObserver(this, "document-on-opening-request");
+ Services.obs.addObserver(this, "network-monitor-alternate-stack");
+ ChannelEventSinkFactory.getService().registerCollector(this);
+ }
+
+ /**
+ * Allows clearing of network stacktrace resources
+ */
+ clear() {
+ this.stacktraces.clear();
+ }
+
+ /**
+ * Stop watching for network event's strack traces related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should stop observing the strack traces
+ */
+ destroy(targetActor) {
+ this.clear();
+ Services.obs.removeObserver(this, "http-on-opening-request");
+ Services.obs.removeObserver(this, "document-on-opening-request");
+ Services.obs.removeObserver(this, "network-monitor-alternate-stack");
+ ChannelEventSinkFactory.getService().unregisterCollector(this);
+ }
+
+ onChannelRedirect(oldChannel, newChannel, flags) {
+ // We can be called with any nsIChannel, but are interested only in HTTP channels
+ try {
+ oldChannel.QueryInterface(Ci.nsIHttpChannel);
+ newChannel.QueryInterface(Ci.nsIHttpChannel);
+ } catch (ex) {
+ return;
+ }
+
+ const oldId = oldChannel.channelId;
+ const stacktrace = this.stacktraces.get(oldId);
+ if (stacktrace) {
+ this._setStackTrace(newChannel.channelId, stacktrace);
+ }
+ }
+
+ observe(subject, topic, data) {
+ let channel, id;
+ try {
+ // We need to QI nsIHttpChannel in order to load the interface's
+ // methods / attributes for later code that could assume we are dealing
+ // with a nsIHttpChannel.
+ channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ id = channel.channelId;
+ } catch (e1) {
+ try {
+ channel = subject.QueryInterface(Ci.nsIIdentChannel);
+ id = channel.channelId;
+ } catch (e2) {
+ // WebSocketChannels do not have IDs, so use the serial. When a WebSocket is
+ // opened in a content process, a channel is created locally but the HTTP
+ // channel for the connection lives entirely in the parent process. When
+ // the server code running in the parent sees that HTTP channel, it will
+ // look for the creation stack using the websocket's serial.
+ try {
+ channel = subject.QueryInterface(Ci.nsIWebSocketChannel);
+ id = channel.serial;
+ } catch (e3) {
+ // Try if the channel is a nsIWorkerChannelInfo which is the substitute
+ // of the channel in the parent process.
+ try {
+ channel = subject.QueryInterface(Ci.nsIWorkerChannelInfo);
+ id = channel.channelId;
+ } catch (e4) {
+ // Channels which don't implement the above interfaces can appear here,
+ // such as nsIFileChannel. Ignore these channels.
+ return;
+ }
+ }
+ }
+ }
+
+ if (
+ !lazy.NetworkUtils.matchRequest(channel, {
+ targetActor: this.targetActor,
+ })
+ ) {
+ return;
+ }
+
+ if (this.stacktraces.has(id)) {
+ // We can get up to two stack traces for the same channel: one each from
+ // the two observer topics we are listening to. Use the first stack trace
+ // which is specified, and ignore any later one.
+ return;
+ }
+
+ const stacktrace = [];
+ switch (topic) {
+ case "http-on-opening-request":
+ case "document-on-opening-request": {
+ // The channel is being opened on the main thread, associate the current
+ // stack with it.
+ //
+ // Convert the nsIStackFrame XPCOM objects to a nice JSON that can be
+ // passed around through message managers etc.
+ let frame = Components.stack;
+ if (frame?.caller) {
+ frame = frame.caller;
+ while (frame) {
+ stacktrace.push({
+ filename: frame.filename,
+ lineNumber: frame.lineNumber,
+ columnNumber: frame.columnNumber,
+ functionName: frame.name,
+ asyncCause: frame.asyncCause,
+ });
+ frame = frame.caller || frame.asyncCaller;
+ }
+ }
+ break;
+ }
+ case "network-monitor-alternate-stack": {
+ // An alternate stack trace is being specified for this channel.
+ // The topic data is the JSON for the saved frame stack we should use,
+ // so convert this into the expected format.
+ //
+ // This topic is used in the following cases:
+ //
+ // - The HTTP channel is opened asynchronously or on a different thread
+ // from the code which triggered its creation, in which case the stack
+ // from Components.stack will be empty. The alternate stack will be
+ // for the point we want to associate with the channel.
+ //
+ // - The channel is not a nsIHttpChannel, and we will receive no
+ // opening request notification for it.
+ let frame = JSON.parse(data);
+ while (frame) {
+ stacktrace.push({
+ filename: frame.source,
+ lineNumber: frame.line,
+ columnNumber: frame.column,
+ functionName: frame.functionDisplayName,
+ asyncCause: frame.asyncCause,
+ });
+ frame = frame.parent || frame.asyncParent;
+ }
+ break;
+ }
+ default:
+ throw new Error("Unexpected observe() topic");
+ }
+
+ this._setStackTrace(id, stacktrace);
+ }
+
+ _setStackTrace(resourceId, stacktrace) {
+ this.stacktraces.set(resourceId, stacktrace);
+ this.onStackTraceAvailable([
+ {
+ resourceType: NETWORK_EVENT_STACKTRACE,
+ resourceId,
+ stacktraceAvailable: stacktrace && !!stacktrace.length,
+ lastFrame: stacktrace && stacktrace.length ? stacktrace[0] : undefined,
+ },
+ ]);
+ }
+
+ getStackTrace(id) {
+ let stacktrace = [];
+ if (this.stacktraces.has(id)) {
+ stacktrace = this.stacktraces.get(id);
+ }
+ return stacktrace;
+ }
+}
+module.exports = NetworkEventStackTracesWatcher;
diff --git a/devtools/server/actors/resources/network-events.js b/devtools/server/actors/resources/network-events.js
new file mode 100644
index 0000000000..c1440f2c8d
--- /dev/null
+++ b/devtools/server/actors/resources/network-events.js
@@ -0,0 +1,420 @@
+/* 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 { Pool } = require("resource://devtools/shared/protocol/Pool.js");
+const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs"
+);
+const { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NetworkObserver:
+ "resource://devtools/shared/network-observer/NetworkObserver.sys.mjs",
+ NetworkUtils:
+ "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
+});
+
+loader.lazyRequireGetter(
+ this,
+ "NetworkEventActor",
+ "resource://devtools/server/actors/network-monitor/network-event-actor.js",
+ true
+);
+
+/**
+ * Handles network events from the parent process
+ */
+class NetworkEventWatcher {
+ /**
+ * Start watching for all network events related to a given Watcher Actor.
+ *
+ * @param WatcherActor watcherActor
+ * The watcher actor in the parent process from which we should
+ * observe network events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ * - onUpdated: optional function
+ * This would be called multiple times for each resource.
+ */
+ async watch(watcherActor, { onAvailable, onUpdated }) {
+ this.networkEvents = new Map();
+
+ this.watcherActor = watcherActor;
+ this.onNetworkEventAvailable = onAvailable;
+ this.onNetworkEventUpdated = onUpdated;
+ // Boolean to know if we keep previous document network events or not.
+ this.persist = false;
+ this.listener = new lazy.NetworkObserver({
+ ignoreChannelFunction: this.shouldIgnoreChannel.bind(this),
+ onNetworkEvent: this.onNetworkEvent.bind(this),
+ });
+
+ Services.obs.addObserver(this, "window-global-destroyed");
+ }
+
+ /**
+ * Clear all the network events and the related actors.
+ *
+ * This is called on actor destroy, but also from WatcherActor.clearResources(NETWORK_EVENT)
+ */
+ clear() {
+ this.networkEvents.clear();
+ this.listener.clear();
+ if (this._pool) {
+ this._pool.destroy();
+ this._pool = null;
+ }
+ }
+
+ /**
+ * A protocol.js Pool to store all NetworkEventActor's which may be destroyed on navigations.
+ */
+ get pool() {
+ if (this._pool) {
+ return this._pool;
+ }
+ this._pool = new Pool(this.watcherActor.conn, "network-events");
+ this.watcherActor.manage(this._pool);
+ return this._pool;
+ }
+
+ /**
+ * Instruct to keep reference to previous document requests or not.
+ * If persist is disabled, we will clear all informations about previous document
+ * on each navigation.
+ * If persist is enabled, we will keep all informations for all documents, leading
+ * to lots of allocations!
+ *
+ * @param {Boolean} enabled
+ */
+ setPersist(enabled) {
+ this.persist = enabled;
+ }
+
+ /**
+ * Gets the throttle settings
+ *
+ * @return {*} data
+ *
+ */
+ getThrottleData() {
+ return this.listener.getThrottleData();
+ }
+
+ /**
+ * Sets the throttle data
+ *
+ * @param {*} data
+ *
+ */
+ setThrottleData(data) {
+ this.listener.setThrottleData(data);
+ }
+
+ /**
+ * Instruct to save or ignore request and response bodies
+ * @param {Boolean} save
+ */
+ setSaveRequestAndResponseBodies(save) {
+ this.listener.setSaveRequestAndResponseBodies(save);
+ }
+
+ /**
+ * Block requests based on the filters
+ * @param {Object} filters
+ */
+ blockRequest(filters) {
+ this.listener.blockRequest(filters);
+ }
+
+ /**
+ * Unblock requests based on the fitlers
+ * @param {Object} filters
+ */
+ unblockRequest(filters) {
+ this.listener.unblockRequest(filters);
+ }
+
+ /**
+ * Calls the listener to set blocked urls
+ *
+ * @param {Array} urls
+ * The urls to block
+ */
+
+ setBlockedUrls(urls) {
+ this.listener.setBlockedUrls(urls);
+ }
+
+ /**
+ * Calls the listener to get the blocked urls
+ *
+ * @return {Array} urls
+ * The blocked urls
+ */
+
+ getBlockedUrls() {
+ return this.listener.getBlockedUrls();
+ }
+
+ override(url, path) {
+ this.listener.override(url, path);
+ }
+
+ removeOverride(url) {
+ this.listener.removeOverride(url);
+ }
+
+ /**
+ * Watch for previous document being unloaded in order to clear
+ * all related network events, in case persist is disabled.
+ * (which is the default behavior)
+ */
+ observe(windowGlobal, topic) {
+ if (topic !== "window-global-destroyed") {
+ return;
+ }
+ // If we persist, we will keep all requests allocated.
+ // For now, consider that the Browser console and toolbox persist all the requests.
+ if (this.persist || this.watcherActor.sessionContext.type == "all") {
+ return;
+ }
+ // Only process WindowGlobals which are related to the debugged scope.
+ if (
+ !isWindowGlobalPartOfContext(
+ windowGlobal,
+ this.watcherActor.sessionContext
+ )
+ ) {
+ return;
+ }
+ const { innerWindowId } = windowGlobal;
+
+ for (const child of this.pool.poolChildren()) {
+ // Destroy all network events matching the destroyed WindowGlobal
+ if (!child.isNavigationRequest()) {
+ if (child.getInnerWindowId() == innerWindowId) {
+ child.destroy();
+ }
+ // Avoid destroying the navigation request, which is flagged with previous document's innerWindowId.
+ // When navigating, the WindowGlobal we navigate *from* will be destroyed and notified here.
+ // We should explicitly avoid destroying it here.
+ // But, we still want to eventually destroy them.
+ // So do this when navigating a second time, we will navigate from a distinct WindowGlobal
+ // and check that this is the top level window global and not an iframe one.
+ // So that we avoid clearing the top navigation when an iframe navigates
+ //
+ // Avoid destroying the request if innerWindowId isn't set. This happens when we reload many times in a row.
+ // The previous navigation request will be cancelled and because of that its innerWindowId will be null.
+ // But the frontend will receive it after the navigation begins (after will-navigate) and will display it
+ // and try to fetch extra data about it. So, avoid destroying its NetworkEventActor.
+ } else if (
+ child.getInnerWindowId() &&
+ child.getInnerWindowId() != innerWindowId &&
+ windowGlobal.browsingContext ==
+ this.watcherActor.browserElement?.browsingContext
+ ) {
+ child.destroy();
+ }
+ }
+ }
+
+ /**
+ * Called by NetworkObserver in order to know if the channel should be ignored
+ */
+ shouldIgnoreChannel(channel) {
+ // First of all, check if the channel matches the watcherActor's session.
+ const filters = { sessionContext: this.watcherActor.sessionContext };
+ if (!lazy.NetworkUtils.matchRequest(channel, filters)) {
+ return true;
+ }
+
+ // When we are in the browser toolbox in parent process scope,
+ // the session context is still "all", but we are no longer watching frame and process targets.
+ // In this case, we should ignore all requests belonging to a BrowsingContext that isn't in the parent process
+ // (i.e. the process where this Watcher runs)
+ const isParentProcessOnlyBrowserToolbox =
+ this.watcherActor.sessionContext.type == "all" &&
+ !WatcherRegistry.isWatchingTargets(
+ this.watcherActor,
+ Targets.TYPES.FRAME
+ );
+ if (isParentProcessOnlyBrowserToolbox) {
+ // We should ignore all requests coming from BrowsingContext running in another process
+ const browsingContextID =
+ lazy.NetworkUtils.getChannelBrowsingContextID(channel);
+ const browsingContext = BrowsingContext.get(browsingContextID);
+ // We accept any request that isn't bound to any BrowsingContext.
+ // This is most likely a privileged request done from a JSM/C++.
+ // `isInProcess` will be true, when the document executes in the parent process.
+ //
+ // Note that we will still accept all requests that aren't bound to any BrowsingContext
+ // See browser_resources_network_events_parent_process.js test with privileged request
+ // made from the content processes.
+ // We miss some attribute on channel/loadInfo to know that it comes from the content process.
+ if (browsingContext?.currentWindowGlobal.isInProcess === false) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ onNetworkEvent(networkEventOptions, channel) {
+ if (channel.channelId && this.networkEvents.has(channel.channelId)) {
+ throw new Error(
+ `Got notified about channel ${channel.channelId} more than once.`
+ );
+ }
+
+ const actor = new NetworkEventActor(
+ this.watcherActor.conn,
+ this.watcherActor.sessionContext,
+ {
+ onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this),
+ onNetworkEventDestroy: this.onNetworkEventDestroy.bind(this),
+ },
+ networkEventOptions,
+ channel
+ );
+ this.pool.manage(actor);
+
+ const resource = actor.asResource();
+ const isBlocked = !!resource.blockedReason;
+ const networkEvent = {
+ browsingContextID: resource.browsingContextID,
+ innerWindowId: resource.innerWindowId,
+ resourceId: resource.resourceId,
+ resourceType: resource.resourceType,
+ isBlocked,
+ isFileRequest: resource.isFileRequest,
+ receivedUpdates: [],
+ resourceUpdates: {
+ // Requests already come with request cookies and headers, so those
+ // should always be considered as available. But the client still
+ // heavily relies on those `Available` flags to fetch additional data,
+ // so it is better to keep them for consistency.
+ requestCookiesAvailable: true,
+ requestHeadersAvailable: true,
+ },
+ };
+ this.networkEvents.set(resource.resourceId, networkEvent);
+
+ this.onNetworkEventAvailable([resource]);
+
+ // Blocked requests will not receive further updates and should emit an
+ // update packet immediately.
+ // The frontend expects to receive a dedicated update to consider the
+ // request as completed. TODO: lift this restriction so that we can only
+ // emit a resource available notification if no update is needed.
+ if (isBlocked) {
+ this._emitUpdate(networkEvent);
+ }
+
+ return actor;
+ }
+
+ onNetworkEventUpdate(updateResource) {
+ const networkEvent = this.networkEvents.get(updateResource.resourceId);
+
+ if (!networkEvent) {
+ return;
+ }
+
+ const { resourceUpdates, receivedUpdates } = networkEvent;
+
+ switch (updateResource.updateType) {
+ case "responseStart":
+ resourceUpdates.httpVersion = updateResource.httpVersion;
+ resourceUpdates.status = updateResource.status;
+ resourceUpdates.statusText = updateResource.statusText;
+ resourceUpdates.remoteAddress = updateResource.remoteAddress;
+ resourceUpdates.remotePort = updateResource.remotePort;
+ // The mimetype is only set when then the contentType is available
+ // in the _onResponseHeader and not for cached/service worker requests
+ // in _httpResponseExaminer.
+ resourceUpdates.mimeType = updateResource.mimeType;
+ resourceUpdates.waitingTime = updateResource.waitingTime;
+ resourceUpdates.isResolvedByTRR = updateResource.isResolvedByTRR;
+ resourceUpdates.proxyHttpVersion = updateResource.proxyHttpVersion;
+ resourceUpdates.proxyStatus = updateResource.proxyStatus;
+ resourceUpdates.proxyStatusText = updateResource.proxyStatusText;
+
+ resourceUpdates.responseHeadersAvailable = true;
+ resourceUpdates.responseCookiesAvailable = true;
+ break;
+ case "responseContent":
+ resourceUpdates.contentSize = updateResource.contentSize;
+ resourceUpdates.transferredSize = updateResource.transferredSize;
+ resourceUpdates.mimeType = updateResource.mimeType;
+ resourceUpdates.blockingExtension = updateResource.blockingExtension;
+ resourceUpdates.blockedReason = updateResource.blockedReason;
+ break;
+ case "eventTimings":
+ resourceUpdates.totalTime = updateResource.totalTime;
+ break;
+ case "securityInfo":
+ resourceUpdates.securityState = updateResource.state;
+ resourceUpdates.isRacing = updateResource.isRacing;
+ break;
+ }
+
+ resourceUpdates[`${updateResource.updateType}Available`] = true;
+ receivedUpdates.push(updateResource.updateType);
+
+ const isComplete = networkEvent.isFileRequest
+ ? receivedUpdates.includes("responseStart")
+ : receivedUpdates.includes("eventTimings") &&
+ receivedUpdates.includes("responseContent") &&
+ receivedUpdates.includes("securityInfo");
+
+ if (isComplete) {
+ this._emitUpdate(networkEvent);
+ }
+ }
+
+ _emitUpdate(networkEvent) {
+ this.onNetworkEventUpdated([
+ {
+ resourceType: networkEvent.resourceType,
+ resourceId: networkEvent.resourceId,
+ resourceUpdates: networkEvent.resourceUpdates,
+ browsingContextID: networkEvent.browsingContextID,
+ innerWindowId: networkEvent.innerWindowId,
+ },
+ ]);
+ }
+
+ onNetworkEventDestroy(channelId) {
+ if (this.networkEvents.has(channelId)) {
+ this.networkEvents.delete(channelId);
+ }
+ }
+
+ /**
+ * Stop watching for network event related to a given Watcher Actor.
+ */
+ destroy() {
+ if (this.listener) {
+ this.clear();
+ this.listener.destroy();
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ }
+ }
+}
+
+module.exports = NetworkEventWatcher;
diff --git a/devtools/server/actors/resources/parent-process-document-event.js b/devtools/server/actors/resources/parent-process-document-event.js
new file mode 100644
index 0000000000..e156a32fe5
--- /dev/null
+++ b/devtools/server/actors/resources/parent-process-document-event.js
@@ -0,0 +1,174 @@
+/* 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: { DOCUMENT_EVENT },
+} = require("resource://devtools/server/actors/resources/index.js");
+const isEveryFrameTargetEnabled = Services.prefs.getBoolPref(
+ "devtools.every-frame-target.enabled",
+ false
+);
+const {
+ WILL_NAVIGATE_TIME_SHIFT,
+} = require("resource://devtools/server/actors/webconsole/listeners/document-events.js");
+
+class ParentProcessDocumentEventWatcher {
+ /**
+ * Start watching, from the parent process, for DOCUMENT_EVENT's "will-navigate" event related to a given Watcher Actor.
+ *
+ * All other DOCUMENT_EVENT events are implemented from another watcher class, running in the content process.
+ * Note that this other content process watcher will also emit one special edgecase of will-navigate
+ * retlated to the iframe dropdown menu.
+ *
+ * We have to move listen for navigation in the parent to better handle bfcache navigations
+ * and more generally all navigations which are initiated from the parent process.
+ * 'bfcacheInParent' feature enabled many types of navigations to be controlled from the parent process.
+ *
+ * This was especially important to have this implementation in the parent
+ * because the navigation event may be fired too late in the content process.
+ * Leading to will-navigate being emitted *after* the new target we navigate to is notified to the client.
+ *
+ * @param WatcherActor watcherActor
+ * The watcher actor from which we should observe document event
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(watcherActor, { onAvailable }) {
+ this.watcherActor = watcherActor;
+ this.onAvailable = onAvailable;
+
+ // List of listeners keyed by innerWindowId.
+ // Listeners are called as soon as we emitted the will-navigate
+ // resource for the related WindowGlobal.
+ this._onceWillNavigate = new Map();
+
+ // Filter browsing contexts to only have the top BrowsingContext of each tree of BrowsingContexts…
+ const topLevelBrowsingContexts = this.watcherActor
+ .getAllBrowsingContexts()
+ .filter(browsingContext => browsingContext.top == browsingContext);
+
+ // Only register one WebProgressListener per BrowsingContext tree.
+ // We will be notified about children BrowsingContext navigations/state changes via the top level BrowsingContextWebProgressListener,
+ // and BrowsingContextWebProgress.browsingContext attribute will be updated dynamically everytime
+ // we get notified about a child BrowsingContext.
+ // Note that regular web page toolbox will only have one BrowsingContext tree, for the given tab.
+ // But the Browser Toolbox will have many trees to listen to, one per top-level Window, and also one per tab,
+ // as tabs's BrowsingContext context aren't children of their top level window!
+ //
+ // Also save the WebProgress and not the BrowsingContext because `BrowsingContext.webProgress` will be undefined in destroy(),
+ // while it is still valuable to call `webProgress.removeProgressListener`. Otherwise events keeps being notified!!
+ this.webProgresses = topLevelBrowsingContexts.map(
+ browsingContext => browsingContext.webProgress
+ );
+ this.webProgresses.forEach(webProgress => {
+ webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ });
+ }
+
+ /**
+ * Wait for the emission of will-navigate for a given WindowGlobal
+ *
+ * @param Number innerWindowId
+ * WindowGlobal's id we want to track
+ * @return Promise
+ * Resolves immediatly if the WindowGlobal isn't tracked by any target
+ * -or- resolve later, once the WindowGlobal navigates to another document
+ * and will-navigate has been emitted.
+ */
+ onceWillNavigateIsEmitted(innerWindowId) {
+ // Only delay the target-destroyed event if the target is for BrowsingContext for which we will emit will-navigate
+ const isTracked = this.webProgresses.find(
+ webProgress =>
+ webProgress.browsingContext.currentWindowGlobal.innerWindowId ==
+ innerWindowId
+ );
+ if (isTracked) {
+ return new Promise(resolve => {
+ this._onceWillNavigate.set(innerWindowId, resolve);
+ });
+ }
+ return Promise.resolve();
+ }
+
+ onStateChange(progress, request, flag, status) {
+ const isStart = flag & Ci.nsIWebProgressListener.STATE_START;
+ const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
+ if (isDocument && isStart) {
+ const { browsingContext } = progress;
+ // Ignore navigation for same-process iframes when EFT is disabled
+ if (
+ !browsingContext.currentWindowGlobal.isProcessRoot &&
+ !isEveryFrameTargetEnabled
+ ) {
+ return;
+ }
+ // Ignore if we are still on the initial document,
+ // as that's the navigation from it (about:blank) to the actual first location.
+ // The target isn't created yet.
+ if (browsingContext.currentWindowGlobal.isInitialDocument) {
+ return;
+ }
+
+ // Only emit will-navigate for top-level targets.
+ if (
+ this.watcherActor.sessionContext.type == "all" &&
+ browsingContext.isContent
+ ) {
+ // Never emit will-navigate for content browsing contexts in the Browser Toolbox.
+ // They might verify `browsingContext.top == browsingContext` because of the chrome/content
+ // boundary, but they do not represent a top-level target for this DevTools session.
+ return;
+ }
+ const isTopLevel = browsingContext.top == browsingContext;
+ if (!isTopLevel) {
+ return;
+ }
+
+ const newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null;
+ const { innerWindowId } = browsingContext.currentWindowGlobal;
+ this.onAvailable([
+ {
+ browsingContextID: browsingContext.id,
+ innerWindowId,
+ resourceType: DOCUMENT_EVENT,
+ name: "will-navigate",
+ time: Date.now() - WILL_NAVIGATE_TIME_SHIFT,
+ isFrameSwitching: false,
+ newURI,
+ },
+ ]);
+ const callback = this._onceWillNavigate.get(innerWindowId);
+ if (callback) {
+ this._onceWillNavigate.delete(innerWindowId);
+ callback();
+ }
+ }
+ }
+
+ get QueryInterface() {
+ return ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]);
+ }
+
+ destroy() {
+ this.webProgresses.forEach(webProgress => {
+ webProgress.removeProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ });
+ this.webProgresses = null;
+ }
+}
+
+module.exports = ParentProcessDocumentEventWatcher;
diff --git a/devtools/server/actors/resources/platform-messages.js b/devtools/server/actors/resources/platform-messages.js
new file mode 100644
index 0000000000..6d9750c0a2
--- /dev/null
+++ b/devtools/server/actors/resources/platform-messages.js
@@ -0,0 +1,60 @@
+/* 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 nsIConsoleListenerWatcher = require("resource://devtools/server/actors/resources/utils/nsi-console-listener-watcher.js");
+
+const {
+ TYPES: { PLATFORM_MESSAGE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const {
+ createStringGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+
+class PlatformMessageWatcher extends nsIConsoleListenerWatcher {
+ shouldHandleTarget(targetActor) {
+ return this.isProcessTarget(targetActor);
+ }
+
+ /**
+ * Returns true if the message is considered a platform message, and as a result, should
+ * be sent to the client.
+ *
+ * @param {TargetActor} targetActor
+ * @param {nsIConsoleMessage} message
+ */
+ shouldHandleMessage(targetActor, message) {
+ // The listener we use can be called either with a nsIConsoleMessage or as nsIScriptError.
+ // In this file, we want to ignore nsIScriptError, which are handled by the
+ // error-messages resource handler (See Bug 1644186).
+ if (message instanceof Ci.nsIScriptError) {
+ return false;
+ }
+
+ // Ignore message that were forwarded from the content process to the parent process,
+ // since we're getting those directly from the content process.
+ if (message.isForwardedFromContentProcess) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns an object from the nsIConsoleMessage.
+ *
+ * @param {Actor} targetActor
+ * @param {nsIConsoleMessage} message
+ */
+ buildResource(targetActor, message) {
+ return {
+ message: createStringGrip(targetActor, message.message),
+ timeStamp: message.microSecondTimeStamp / 1000,
+ resourceType: PLATFORM_MESSAGE,
+ };
+ }
+}
+module.exports = PlatformMessageWatcher;
diff --git a/devtools/server/actors/resources/reflow.js b/devtools/server/actors/resources/reflow.js
new file mode 100644
index 0000000000..5be9d6e7b2
--- /dev/null
+++ b/devtools/server/actors/resources/reflow.js
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ TYPES: { REFLOW },
+} = require("resource://devtools/server/actors/resources/index.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const {
+ getLayoutChangesObserver,
+ releaseLayoutChangesObserver,
+} = require("resource://devtools/server/actors/reflow.js");
+
+class ReflowWatcher {
+ /**
+ * Start watching for reflows related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe reflows
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ // Only track reflow for non-ParentProcess FRAME targets
+ if (
+ targetActor.targetType !== Targets.TYPES.FRAME ||
+ targetActor.typeName === "parentProcessTarget"
+ ) {
+ return;
+ }
+
+ this._targetActor = targetActor;
+
+ const onReflows = reflows => {
+ onAvailable([
+ {
+ resourceType: REFLOW,
+ reflows,
+ },
+ ]);
+ };
+
+ this._observer = getLayoutChangesObserver(targetActor);
+ this._offReflows = this._observer.on("reflows", onReflows);
+ this._observer.start();
+ }
+
+ destroy() {
+ releaseLayoutChangesObserver(this._targetActor);
+
+ if (this._offReflows) {
+ this._offReflows();
+ this._offReflows = null;
+ }
+ }
+}
+
+module.exports = ReflowWatcher;
diff --git a/devtools/server/actors/resources/server-sent-events.js b/devtools/server/actors/resources/server-sent-events.js
new file mode 100644
index 0000000000..5b16f8bb9f
--- /dev/null
+++ b/devtools/server/actors/resources/server-sent-events.js
@@ -0,0 +1,135 @@
+/* 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 {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+const {
+ TYPES: { SERVER_SENT_EVENT },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const eventSourceEventService = Cc[
+ "@mozilla.org/eventsourceevent/service;1"
+].getService(Ci.nsIEventSourceEventService);
+
+class ServerSentEventWatcher {
+ constructor() {
+ this.windowIds = new Set();
+ // Register for backend events.
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroy = this.onWindowDestroy.bind(this);
+ }
+ /**
+ * Start watching for all server sent events related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor on which we should observe server sent events.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ watch(targetActor, { onAvailable }) {
+ this.onAvailable = onAvailable;
+ this.targetActor = targetActor;
+
+ for (const window of this.targetActor.windows) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+
+ // Listen for subsequent top-level-document reloads/navigations,
+ // new iframe additions or current iframe reloads/navigation.
+ this.targetActor.on("window-ready", this.onWindowReady);
+ this.targetActor.on("window-destroyed", this.onWindowDestroy);
+ }
+
+ static createResource(messageType, eventParams) {
+ return {
+ resourceType: SERVER_SENT_EVENT,
+ messageType,
+ ...eventParams,
+ };
+ }
+
+ static prepareFramePayload(targetActor, frame) {
+ const payload = new LongStringActor(targetActor.conn, frame);
+ targetActor.manage(payload);
+ return payload.form();
+ }
+
+ onWindowReady({ window }) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+
+ onWindowDestroy({ id }) {
+ this.stopListening(id);
+ }
+
+ startListening(innerWindowId) {
+ if (!this.windowIds.has(innerWindowId)) {
+ this.windowIds.add(innerWindowId);
+ eventSourceEventService.addListener(innerWindowId, this);
+ }
+ }
+
+ stopListening(innerWindowId) {
+ if (this.windowIds.has(innerWindowId)) {
+ this.windowIds.delete(innerWindowId);
+ // The listener might have already been cleaned up on `window-destroy`.
+ if (!eventSourceEventService.hasListenerFor(innerWindowId)) {
+ console.warn(
+ "Already stopped listening to server sent events for this window."
+ );
+ return;
+ }
+ eventSourceEventService.removeListener(innerWindowId, this);
+ }
+ }
+
+ destroy() {
+ // cleanup any other listeners not removed on `window-destroy`
+ for (const id of this.windowIds) {
+ this.stopListening(id);
+ }
+ this.targetActor.off("window-ready", this.onWindowReady);
+ this.targetActor.off("window-destroyed", this.onWindowDestroy);
+ }
+
+ // nsIEventSourceEventService specific functions
+ eventSourceConnectionOpened(httpChannelId) {}
+
+ eventSourceConnectionClosed(httpChannelId) {
+ const resource = ServerSentEventWatcher.createResource(
+ "eventSourceConnectionClosed",
+ { httpChannelId }
+ );
+ this.onAvailable([resource]);
+ }
+
+ eventReceived(httpChannelId, eventName, lastEventId, data, retry, timeStamp) {
+ const payload = ServerSentEventWatcher.prepareFramePayload(
+ this.targetActor,
+ data
+ );
+ const resource = ServerSentEventWatcher.createResource("eventReceived", {
+ httpChannelId,
+ data: {
+ payload,
+ eventName,
+ lastEventId,
+ retry,
+ timeStamp,
+ },
+ });
+
+ this.onAvailable([resource]);
+ }
+}
+
+module.exports = ServerSentEventWatcher;
diff --git a/devtools/server/actors/resources/sources.js b/devtools/server/actors/resources/sources.js
new file mode 100644
index 0000000000..6b3ab1d5e1
--- /dev/null
+++ b/devtools/server/actors/resources/sources.js
@@ -0,0 +1,100 @@
+/* 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: { SOURCE },
+} = require("resource://devtools/server/actors/resources/index.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const {
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+/**
+ * Start watching for all JS sources related to a given Target Actor.
+ * This will notify about existing sources, but also the ones created in future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe sources
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+class SourceWatcher {
+ constructor() {
+ this.onNewSource = this.onNewSource.bind(this);
+ }
+
+ async watch(targetActor, { onAvailable }) {
+ // When debugging the whole browser, we instantiate both content process and browsing context targets.
+ // But sources will only be debugged the content process target, even browsing context sources.
+ if (
+ targetActor.sessionContext.type == "all" &&
+ targetActor.targetType === Targets.TYPES.FRAME &&
+ targetActor.typeName != "parentProcessTarget"
+ ) {
+ return;
+ }
+
+ const { threadActor } = targetActor;
+ this.sourcesManager = targetActor.sourcesManager;
+ this.onAvailable = onAvailable;
+
+ // Disable `ThreadActor.newSource` RDP event in order to avoid unnecessary traffic
+ threadActor.disableNewSourceEvents();
+
+ threadActor.sourcesManager.on("newSource", this.onNewSource);
+
+ // For WindowGlobal, Content process and Service Worker targets,
+ // the thread actor is fully managed by the server codebase.
+ // For these targets, the actor should be "attached" (initialized) right away in order
+ // to start observing the sources.
+ //
+ // For regular and shared Workers, the thread actor is still managed by the client.
+ // The client will call `attach` (bug 1691986) later, which will also resume worker execution.
+ const isTargetCreation = threadActor.state == THREAD_STATES.DETACHED;
+ const { targetType } = targetActor;
+ if (
+ isTargetCreation &&
+ targetType != Targets.TYPES.WORKER &&
+ targetType != Targets.TYPES.SHARED_WORKER
+ ) {
+ await threadActor.attach({});
+ }
+
+ // Before fetching all sources, process existing ones.
+ // The ThreadActor is already up and running before this code runs
+ // and have sources already registered and for which newSource event already fired.
+ onAvailable(
+ threadActor.sourcesManager.iter().map(s => {
+ const resource = s.form();
+ resource.resourceType = SOURCE;
+ return resource;
+ })
+ );
+
+ // Requesting all sources should end up emitting newSource on threadActor.sourcesManager
+ threadActor.addAllSources();
+ }
+
+ /**
+ * Stop watching for sources
+ */
+ destroy() {
+ if (this.sourcesManager) {
+ this.sourcesManager.off("newSource", this.onNewSource);
+ }
+ }
+
+ onNewSource(source) {
+ const resource = source.form();
+ resource.resourceType = SOURCE;
+ this.onAvailable([resource]);
+ }
+}
+
+module.exports = SourceWatcher;
diff --git a/devtools/server/actors/resources/storage-cache.js b/devtools/server/actors/resources/storage-cache.js
new file mode 100644
index 0000000000..73a2bba40f
--- /dev/null
+++ b/devtools/server/actors/resources/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/server/actors/resources/index.js");
+
+const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js");
+const {
+ CacheStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/cache.js");
+
+class CacheWatcher extends ContentProcessStorage {
+ constructor() {
+ super(CacheStorageActor, "Cache", CACHE_STORAGE);
+ }
+}
+
+module.exports = CacheWatcher;
diff --git a/devtools/server/actors/resources/storage-cookie.js b/devtools/server/actors/resources/storage-cookie.js
new file mode 100644
index 0000000000..8d847a5bf0
--- /dev/null
+++ b/devtools/server/actors/resources/storage-cookie.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: { COOKIE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js");
+const {
+ CookiesStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/cookies.js");
+
+class CookiesWatcher extends ParentProcessStorage {
+ constructor() {
+ super(CookiesStorageActor, "cookies", COOKIE);
+ }
+}
+
+module.exports = CookiesWatcher;
diff --git a/devtools/server/actors/resources/storage-extension.js b/devtools/server/actors/resources/storage-extension.js
new file mode 100644
index 0000000000..daacd40778
--- /dev/null
+++ b/devtools/server/actors/resources/storage-extension.js
@@ -0,0 +1,30 @@
+/* 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/server/actors/resources/index.js");
+
+const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js");
+const {
+ ExtensionStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/extension-storage.js");
+
+class ExtensionStorageWatcher extends ParentProcessStorage {
+ constructor() {
+ super(ExtensionStorageActor, "extensionStorage", EXTENSION_STORAGE);
+ }
+ async watch(watcherActor, { onAvailable }) {
+ if (watcherActor.sessionContext.type != "webextension") {
+ throw new Error(
+ "EXTENSION_STORAGE should only be listened when debugging a webextension"
+ );
+ }
+ return super.watch(watcherActor, { onAvailable });
+ }
+}
+
+module.exports = ExtensionStorageWatcher;
diff --git a/devtools/server/actors/resources/storage-indexed-db.js b/devtools/server/actors/resources/storage-indexed-db.js
new file mode 100644
index 0000000000..88ee01a000
--- /dev/null
+++ b/devtools/server/actors/resources/storage-indexed-db.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: { INDEXED_DB },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const ParentProcessStorage = require("resource://devtools/server/actors/resources/utils/parent-process-storage.js");
+const {
+ IndexedDBStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/indexed-db.js");
+
+class IndexedDBWatcher extends ParentProcessStorage {
+ constructor() {
+ super(IndexedDBStorageActor, "indexedDB", INDEXED_DB);
+ }
+}
+
+module.exports = IndexedDBWatcher;
diff --git a/devtools/server/actors/resources/storage-local-storage.js b/devtools/server/actors/resources/storage-local-storage.js
new file mode 100644
index 0000000000..54b5ea4d5b
--- /dev/null
+++ b/devtools/server/actors/resources/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/server/actors/resources/index.js");
+
+const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js");
+const {
+ LocalStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/local-and-session-storage.js");
+
+class LocalStorageWatcher extends ContentProcessStorage {
+ constructor() {
+ super(LocalStorageActor, "localStorage", LOCAL_STORAGE);
+ }
+}
+
+module.exports = LocalStorageWatcher;
diff --git a/devtools/server/actors/resources/storage-session-storage.js b/devtools/server/actors/resources/storage-session-storage.js
new file mode 100644
index 0000000000..fa980aa9f1
--- /dev/null
+++ b/devtools/server/actors/resources/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/server/actors/resources/index.js");
+
+const ContentProcessStorage = require("resource://devtools/server/actors/resources/utils/content-process-storage.js");
+const {
+ SessionStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/local-and-session-storage.js");
+
+class SessionStorageWatcher extends ContentProcessStorage {
+ constructor() {
+ super(SessionStorageActor, "sessionStorage", SESSION_STORAGE);
+ }
+}
+
+module.exports = SessionStorageWatcher;
diff --git a/devtools/server/actors/resources/storage/cache.js b/devtools/server/actors/resources/storage/cache.js
new file mode 100644
index 0000000000..2066d181e0
--- /dev/null
+++ b/devtools/server/actors/resources/storage/cache.js
@@ -0,0 +1,195 @@
+/* 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 {
+ BaseStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+
+class CacheStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "Cache");
+ }
+
+ async populateStoresForHost(host) {
+ const storeMap = new Map();
+ const caches = await this.getCachesForHost(host);
+ try {
+ for (const name of await caches.keys()) {
+ storeMap.set(name, await caches.open(name));
+ }
+ } catch (ex) {
+ console.warn(`Failed to enumerate CacheStorage for host ${host}: ${ex}`);
+ }
+ this.hostVsStores.set(host, storeMap);
+ }
+
+ async getCachesForHost(host) {
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return null;
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+
+ // The first argument tells if you want to get |content| cache or |chrome|
+ // cache.
+ // The |content| cache is the cache explicitely named by the web content
+ // (service worker or web page).
+ // The |chrome| cache is the cache implicitely cached by the platform,
+ // hosting the source file of the service worker.
+ const { CacheStorage } = win;
+
+ if (!CacheStorage) {
+ return null;
+ }
+
+ const cache = new CacheStorage("content", principal);
+ return cache;
+ }
+
+ form() {
+ const hosts = {};
+ for (const host of this.hosts) {
+ hosts[host] = this.getNamesForHost(host);
+ }
+
+ return {
+ actor: this.actorID,
+ hosts,
+ traits: this._getTraits(),
+ };
+ }
+
+ getNamesForHost(host) {
+ // UI code expect each name to be a JSON string of an array :/
+ return [...this.hostVsStores.get(host).keys()].map(a => {
+ return JSON.stringify([a]);
+ });
+ }
+
+ async getValuesForHost(host, name) {
+ if (!name) {
+ // if we get here, we most likely clicked on the refresh button
+ // which called getStoreObjects, itself calling this method,
+ // all that, without having selected any particular cache name.
+ //
+ // Try to detect if a new cache has been added and notify the client
+ // asynchronously, via a RDP event.
+ const previousCaches = [...this.hostVsStores.get(host).keys()];
+ await this.populateStoresForHosts();
+ const updatedCaches = [...this.hostVsStores.get(host).keys()];
+ const newCaches = updatedCaches.filter(
+ cacheName => !previousCaches.includes(cacheName)
+ );
+ newCaches.forEach(cacheName =>
+ this.onItemUpdated("added", host, [cacheName])
+ );
+ const removedCaches = previousCaches.filter(
+ cacheName => !updatedCaches.includes(cacheName)
+ );
+ removedCaches.forEach(cacheName =>
+ this.onItemUpdated("deleted", host, [cacheName])
+ );
+ return [];
+ }
+ // UI is weird and expect a JSON stringified array... and pass it back :/
+ name = JSON.parse(name)[0];
+
+ const cache = this.hostVsStores.get(host).get(name);
+ const requests = await cache.keys();
+ const results = [];
+ for (const request of requests) {
+ let response = await cache.match(request);
+ // Unwrap the response to get access to all its properties if the
+ // response happen to be 'opaque', when it is a Cross Origin Request.
+ response = response.cloneUnfiltered();
+ results.push(await this.processEntry(request, response));
+ }
+ return results;
+ }
+
+ async processEntry(request, response) {
+ return {
+ url: String(request.url),
+ status: String(response.statusText),
+ };
+ }
+
+ async getFields() {
+ return [
+ { name: "url", editable: false },
+ { name: "status", editable: false },
+ ];
+ }
+
+ /**
+ * Given a url, correctly determine its protocol + hostname part.
+ */
+ getSchemaAndHost(url) {
+ const uri = Services.io.newURI(url);
+ return uri.scheme + "://" + uri.hostPort;
+ }
+
+ toStoreObject(item) {
+ return item;
+ }
+
+ async removeItem(host, name) {
+ const cacheMap = this.hostVsStores.get(host);
+ if (!cacheMap) {
+ return;
+ }
+
+ const parsedName = JSON.parse(name);
+
+ if (parsedName.length == 1) {
+ // Delete the whole Cache object
+ const [cacheName] = parsedName;
+ cacheMap.delete(cacheName);
+ const cacheStorage = await this.getCachesForHost(host);
+ await cacheStorage.delete(cacheName);
+ this.onItemUpdated("deleted", host, [cacheName]);
+ } else if (parsedName.length == 2) {
+ // Delete one cached request
+ const [cacheName, url] = parsedName;
+ const cache = cacheMap.get(cacheName);
+ if (cache) {
+ await cache.delete(url);
+ this.onItemUpdated("deleted", host, [cacheName, url]);
+ }
+ }
+ }
+
+ async removeAll(host, name) {
+ const cacheMap = this.hostVsStores.get(host);
+ if (!cacheMap) {
+ return;
+ }
+
+ const parsedName = JSON.parse(name);
+
+ // Only a Cache object is a valid object to clear
+ if (parsedName.length == 1) {
+ const [cacheName] = parsedName;
+ const cache = cacheMap.get(cacheName);
+ if (cache) {
+ const keys = await cache.keys();
+ await Promise.all(keys.map(key => cache.delete(key)));
+ this.onItemUpdated("cleared", host, [cacheName]);
+ }
+ }
+ }
+
+ /**
+ * CacheStorage API doesn't support any notifications, we must fake them
+ */
+ onItemUpdated(action, host, path) {
+ this.storageActor.update(action, "Cache", {
+ [host]: [JSON.stringify(path)],
+ });
+ }
+}
+exports.CacheStorageActor = CacheStorageActor;
diff --git a/devtools/server/actors/resources/storage/cookies.js b/devtools/server/actors/resources/storage/cookies.js
new file mode 100644
index 0000000000..6a7d90414a
--- /dev/null
+++ b/devtools/server/actors/resources/storage/cookies.js
@@ -0,0 +1,559 @@
+/* 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 {
+ BaseStorageActor,
+ DEFAULT_VALUE,
+ SEPARATOR_GUID,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+// "Lax", "Strict" and "None" are special values of the SameSite property
+// that should not be translated.
+const COOKIE_SAMESITE = {
+ LAX: "Lax",
+ STRICT: "Strict",
+ NONE: "None",
+};
+
+// MAX_COOKIE_EXPIRY should be 2^63-1, but JavaScript can't handle that
+// precision.
+const MAX_COOKIE_EXPIRY = Math.pow(2, 62);
+
+/**
+ * General helpers
+ */
+function trimHttpHttpsPort(url) {
+ const match = url.match(/(.+):\d+$/);
+
+ if (match) {
+ url = match[1];
+ }
+ if (url.startsWith("http://")) {
+ return url.substr(7);
+ }
+ if (url.startsWith("https://")) {
+ return url.substr(8);
+ }
+ return url;
+}
+
+class CookiesStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "cookies");
+
+ Services.obs.addObserver(this, "cookie-changed");
+ Services.obs.addObserver(this, "private-cookie-changed");
+ }
+
+ destroy() {
+ Services.obs.removeObserver(this, "cookie-changed");
+ Services.obs.removeObserver(this, "private-cookie-changed");
+
+ super.destroy();
+ }
+
+ populateStoresForHost(host) {
+ this.hostVsStores.set(host, new Map());
+
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ const cookies = this.getCookiesFromHost(host, originAttributes);
+
+ for (const cookie of cookies) {
+ if (this.isCookieAtHost(cookie, host)) {
+ const uniqueKey =
+ `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
+ `${SEPARATOR_GUID}${cookie.path}`;
+
+ this.hostVsStores.get(host).set(uniqueKey, cookie);
+ }
+ }
+ }
+
+ getOriginAttributesFromHost(host) {
+ const win = this.storageActor.getWindowFromHost(host);
+ let originAttributes;
+ if (win) {
+ originAttributes =
+ win.document.effectiveStoragePrincipal.originAttributes;
+ } else {
+ // If we can't find the window by host, fallback to the top window
+ // origin attributes.
+ originAttributes =
+ this.storageActor.document?.effectiveStoragePrincipal.originAttributes;
+ }
+
+ return originAttributes;
+ }
+
+ getCookiesFromHost(host, originAttributes) {
+ // Local files have no host.
+ if (host.startsWith("file:///")) {
+ host = "";
+ }
+
+ host = trimHttpHttpsPort(host);
+
+ return Services.cookies.getCookiesFromHost(host, originAttributes);
+ }
+
+ /**
+ * Given a cookie object, figure out all the matching hosts from the page that
+ * the cookie belong to.
+ */
+ getMatchingHosts(cookies) {
+ if (!cookies) {
+ return [];
+ }
+ if (!cookies.length) {
+ cookies = [cookies];
+ }
+ const hosts = new Set();
+ for (const host of this.hosts) {
+ for (const cookie of cookies) {
+ if (this.isCookieAtHost(cookie, host)) {
+ hosts.add(host);
+ }
+ }
+ }
+ return [...hosts];
+ }
+
+ /**
+ * Given a cookie object and a host, figure out if the cookie is valid for
+ * that host.
+ */
+ isCookieAtHost(cookie, host) {
+ if (cookie.host == null) {
+ return host == null;
+ }
+
+ host = trimHttpHttpsPort(host);
+
+ if (cookie.host.startsWith(".")) {
+ return ("." + host).endsWith(cookie.host);
+ }
+ if (cookie.host === "") {
+ return host.startsWith("file://" + cookie.path);
+ }
+
+ return cookie.host == host;
+ }
+
+ toStoreObject(cookie) {
+ if (!cookie) {
+ return null;
+ }
+
+ return {
+ uniqueKey:
+ `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
+ `${SEPARATOR_GUID}${cookie.path}`,
+ name: cookie.name,
+ host: cookie.host || "",
+ path: cookie.path || "",
+
+ // because expires is in seconds
+ expires: (cookie.expires || 0) * 1000,
+
+ // because creationTime is in micro seconds
+ creationTime: cookie.creationTime / 1000,
+
+ size: cookie.name.length + (cookie.value || "").length,
+
+ // - do -
+ lastAccessed: cookie.lastAccessed / 1000,
+ value: new LongStringActor(this.conn, cookie.value || ""),
+ hostOnly: !cookie.isDomain,
+ isSecure: cookie.isSecure,
+ isHttpOnly: cookie.isHttpOnly,
+ sameSite: this.getSameSiteStringFromCookie(cookie),
+ };
+ }
+
+ getSameSiteStringFromCookie(cookie) {
+ switch (cookie.sameSite) {
+ case cookie.SAMESITE_LAX:
+ return COOKIE_SAMESITE.LAX;
+ case cookie.SAMESITE_STRICT:
+ return COOKIE_SAMESITE.STRICT;
+ }
+ // cookie.SAMESITE_NONE
+ return COOKIE_SAMESITE.NONE;
+ }
+
+ /**
+ * Notification observer for "cookie-change".
+ *
+ * @param {(nsICookie|nsICookie[])} cookie - Cookie/s changed. Depending on the action
+ * this is either null, a single cookie or an array of cookies.
+ * @param {nsICookieNotification_Action} action - The cookie operation, see
+ * nsICookieNotification for details.
+ **/
+ onCookieChanged(cookie, action) {
+ const {
+ COOKIE_ADDED,
+ COOKIE_CHANGED,
+ COOKIE_DELETED,
+ COOKIES_BATCH_DELETED,
+ ALL_COOKIES_CLEARED,
+ } = Ci.nsICookieNotification;
+
+ const hosts = this.getMatchingHosts(cookie);
+ if (!hosts.length) {
+ return;
+ }
+
+ const data = {};
+
+ switch (action) {
+ case COOKIE_ADDED:
+ case COOKIE_CHANGED:
+ if (hosts.length) {
+ for (const host of hosts) {
+ const uniqueKey =
+ `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
+ `${SEPARATOR_GUID}${cookie.path}`;
+
+ this.hostVsStores.get(host).set(uniqueKey, cookie);
+ data[host] = [uniqueKey];
+ }
+ const actionStr = action == COOKIE_ADDED ? "added" : "changed";
+ this.storageActor.update(actionStr, "cookies", data);
+ }
+ break;
+
+ case COOKIE_DELETED:
+ if (hosts.length) {
+ for (const host of hosts) {
+ const uniqueKey =
+ `${cookie.name}${SEPARATOR_GUID}${cookie.host}` +
+ `${SEPARATOR_GUID}${cookie.path}`;
+
+ this.hostVsStores.get(host).delete(uniqueKey);
+ data[host] = [uniqueKey];
+ }
+ this.storageActor.update("deleted", "cookies", data);
+ }
+ break;
+
+ case COOKIES_BATCH_DELETED:
+ if (hosts.length) {
+ for (const host of hosts) {
+ const stores = [];
+ // For COOKIES_BATCH_DELETED cookie is an array.
+ for (const batchCookie of cookie) {
+ const uniqueKey =
+ `${batchCookie.name}${SEPARATOR_GUID}${batchCookie.host}` +
+ `${SEPARATOR_GUID}${batchCookie.path}`;
+
+ this.hostVsStores.get(host).delete(uniqueKey);
+ stores.push(uniqueKey);
+ }
+ data[host] = stores;
+ }
+ this.storageActor.update("deleted", "cookies", data);
+ }
+ break;
+
+ case ALL_COOKIES_CLEARED:
+ if (hosts.length) {
+ for (const host of hosts) {
+ data[host] = [];
+ }
+ this.storageActor.update("cleared", "cookies", data);
+ }
+ break;
+ }
+ }
+
+ async getFields() {
+ return [
+ { name: "uniqueKey", editable: false, private: true },
+ { name: "name", editable: true, hidden: false },
+ { name: "value", editable: true, hidden: false },
+ { name: "host", editable: true, hidden: false },
+ { name: "path", editable: true, hidden: false },
+ { name: "expires", editable: true, hidden: false },
+ { name: "size", editable: false, hidden: false },
+ { name: "isHttpOnly", editable: true, hidden: false },
+ { name: "isSecure", editable: true, hidden: false },
+ { name: "sameSite", editable: false, hidden: false },
+ { name: "lastAccessed", editable: false, hidden: false },
+ { name: "creationTime", editable: false, hidden: true },
+ { name: "hostOnly", editable: false, hidden: true },
+ ];
+ }
+
+ /**
+ * Pass the editItem command from the content to the chrome process.
+ *
+ * @param {Object} data
+ * See editCookie() for format details.
+ */
+ async editItem(data) {
+ data.originAttributes = this.getOriginAttributesFromHost(data.host);
+ this.editCookie(data);
+ }
+
+ async addItem(guid, host) {
+ const window = this.storageActor.getWindowFromHost(host);
+ const principal = window.document.effectiveStoragePrincipal;
+ this.addCookie(guid, principal);
+ }
+
+ async removeItem(host, name) {
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ this.removeCookie(host, name, originAttributes);
+ }
+
+ async removeAll(host, domain) {
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ this.removeAllCookies(host, domain, originAttributes);
+ }
+
+ async removeAllSessionCookies(host, domain) {
+ const originAttributes = this.getOriginAttributesFromHost(host);
+ this._removeCookies(host, { domain, originAttributes, session: true });
+ }
+
+ addCookie(guid, principal) {
+ // Set expiry time for cookie 1 day into the future
+ // NOTE: Services.cookies.add expects the time in seconds.
+ const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
+ const time = Math.floor(Date.now() / 1000);
+ const expiry = time + ONE_DAY_IN_SECONDS;
+
+ // principal throws an error when we try to access principal.host if it
+ // does not exist (which happens at about: pages).
+ // We check for asciiHost instead, which is always present, and has a
+ // value of "" when the host is not available.
+ const domain = principal.asciiHost ? principal.host : principal.baseDomain;
+
+ Services.cookies.add(
+ domain,
+ "/",
+ guid, // name
+ DEFAULT_VALUE, // value
+ false, // isSecure
+ false, // isHttpOnly,
+ false, // isSession,
+ expiry, // expires,
+ principal.originAttributes, // originAttributes
+ Ci.nsICookie.SAMESITE_LAX, // sameSite
+ principal.scheme === "https" // schemeMap
+ ? Ci.nsICookie.SCHEME_HTTPS
+ : Ci.nsICookie.SCHEME_HTTP
+ );
+ }
+
+ /**
+ * Apply the results of a cookie edit.
+ *
+ * @param {Object} data
+ * An object in the following format:
+ * {
+ * host: "http://www.mozilla.org",
+ * field: "value",
+ * editCookie: "name",
+ * oldValue: "%7BHello%7D",
+ * newValue: "%7BHelloo%7D",
+ * items: {
+ * name: "optimizelyBuckets",
+ * path: "/",
+ * host: ".mozilla.org",
+ * expires: "Mon, 02 Jun 2025 12:37:37 GMT",
+ * creationTime: "Tue, 18 Nov 2014 16:21:18 GMT",
+ * lastAccessed: "Wed, 17 Feb 2016 10:06:23 GMT",
+ * value: "%7BHelloo%7D",
+ * isDomain: "true",
+ * isSecure: "false",
+ * isHttpOnly: "false"
+ * }
+ * }
+ */
+ // eslint-disable-next-line complexity
+ editCookie(data) {
+ let { field, oldValue, newValue } = data;
+ const origName = field === "name" ? oldValue : data.items.name;
+ const origHost = field === "host" ? oldValue : data.items.host;
+ const origPath = field === "path" ? oldValue : data.items.path;
+ let cookie = null;
+
+ const cookies = Services.cookies.getCookiesFromHost(
+ origHost,
+ data.originAttributes || {}
+ );
+ for (const nsiCookie of cookies) {
+ if (
+ nsiCookie.name === origName &&
+ nsiCookie.host === origHost &&
+ nsiCookie.path === origPath
+ ) {
+ cookie = {
+ host: nsiCookie.host,
+ path: nsiCookie.path,
+ name: nsiCookie.name,
+ value: nsiCookie.value,
+ isSecure: nsiCookie.isSecure,
+ isHttpOnly: nsiCookie.isHttpOnly,
+ isSession: nsiCookie.isSession,
+ expires: nsiCookie.expires,
+ originAttributes: nsiCookie.originAttributes,
+ schemeMap: nsiCookie.schemeMap,
+ };
+ break;
+ }
+ }
+
+ if (!cookie) {
+ return;
+ }
+
+ // If the date is expired set it for 10 seconds in the future.
+ const now = new Date();
+ if (!cookie.isSession && cookie.expires * 1000 <= now) {
+ const tenSecondsFromNow = (now.getTime() + 10 * 1000) / 1000;
+
+ cookie.expires = tenSecondsFromNow;
+ }
+
+ switch (field) {
+ case "isSecure":
+ case "isHttpOnly":
+ case "isSession":
+ newValue = newValue === "true";
+ break;
+
+ case "expires":
+ newValue = Date.parse(newValue) / 1000;
+
+ if (isNaN(newValue)) {
+ newValue = MAX_COOKIE_EXPIRY;
+ }
+ break;
+
+ case "host":
+ case "name":
+ case "path":
+ // Remove the edited cookie.
+ Services.cookies.remove(
+ origHost,
+ origName,
+ origPath,
+ cookie.originAttributes
+ );
+ break;
+ }
+
+ // Apply changes.
+ cookie[field] = newValue;
+
+ // cookie.isSession is not always set correctly on session cookies so we
+ // need to trust cookie.expires instead.
+ cookie.isSession = !cookie.expires;
+
+ // Add the edited cookie.
+ Services.cookies.add(
+ cookie.host,
+ cookie.path,
+ cookie.name,
+ cookie.value,
+ cookie.isSecure,
+ cookie.isHttpOnly,
+ cookie.isSession,
+ cookie.isSession ? MAX_COOKIE_EXPIRY : cookie.expires,
+ cookie.originAttributes,
+ cookie.sameSite,
+ cookie.schemeMap
+ );
+ }
+
+ _removeCookies(host, opts = {}) {
+ // We use a uniqueId to emulate compound keys for cookies. We need to
+ // extract the cookie name to remove the correct cookie.
+ if (opts.name) {
+ const split = opts.name.split(SEPARATOR_GUID);
+
+ opts.name = split[0];
+ opts.path = split[2];
+ }
+
+ host = trimHttpHttpsPort(host);
+
+ function hostMatches(cookieHost, matchHost) {
+ if (cookieHost == null) {
+ return matchHost == null;
+ }
+ if (cookieHost.startsWith(".")) {
+ return ("." + matchHost).endsWith(cookieHost);
+ }
+ return cookieHost == host;
+ }
+
+ const cookies = Services.cookies.getCookiesFromHost(
+ host,
+ opts.originAttributes || {}
+ );
+ for (const cookie of cookies) {
+ if (
+ hostMatches(cookie.host, host) &&
+ (!opts.name || cookie.name === opts.name) &&
+ (!opts.domain || cookie.host === opts.domain) &&
+ (!opts.path || cookie.path === opts.path) &&
+ (!opts.session || (!cookie.expires && !cookie.maxAge))
+ ) {
+ Services.cookies.remove(
+ cookie.host,
+ cookie.name,
+ cookie.path,
+ cookie.originAttributes
+ );
+ }
+ }
+ }
+
+ removeCookie(host, name, originAttributes) {
+ if (name !== undefined) {
+ this._removeCookies(host, { name, originAttributes });
+ }
+ }
+
+ removeAllCookies(host, domain, originAttributes) {
+ this._removeCookies(host, { domain, originAttributes });
+ }
+
+ observe(subject, topic) {
+ if (
+ !subject ||
+ (topic != "cookie-changed" && topic != "private-cookie-changed") ||
+ !this.storageActor ||
+ !this.storageActor.windows
+ ) {
+ return;
+ }
+
+ const notification = subject.QueryInterface(Ci.nsICookieNotification);
+ let cookie;
+ if (notification.action == Ci.nsICookieNotification.COOKIES_BATCH_DELETED) {
+ // Extract the batch deleted cookies from nsIArray.
+ const cookiesNoInterface =
+ notification.batchDeletedCookies.QueryInterface(Ci.nsIArray);
+ cookie = [];
+ for (let i = 0; i < cookiesNoInterface.length; i++) {
+ cookie.push(cookiesNoInterface.queryElementAt(i, Ci.nsICookie));
+ }
+ } else if (notification.cookie) {
+ // Otherwise, get the single cookie affected by the operation.
+ cookie = notification.cookie.QueryInterface(Ci.nsICookie);
+ }
+
+ this.onCookieChanged(cookie, notification.action);
+ }
+}
+exports.CookiesStorageActor = CookiesStorageActor;
diff --git a/devtools/server/actors/resources/storage/extension-storage.js b/devtools/server/actors/resources/storage/extension-storage.js
new file mode 100644
index 0000000000..d14d3320c7
--- /dev/null
+++ b/devtools/server/actors/resources/storage/extension-storage.js
@@ -0,0 +1,491 @@
+/* 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 {
+ BaseStorageActor,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ parseItemValue,
+} = require("resource://devtools/shared/storage/utils.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+// Use loadInDevToolsLoader: false for these extension modules, because these
+// are singletons with shared state, and we must not create a new instance if a
+// dedicated loader was used to load this module.
+loader.lazyGetter(this, "ExtensionParent", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionParent.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).ExtensionParent;
+});
+loader.lazyGetter(this, "ExtensionProcessScript", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).ExtensionProcessScript;
+});
+loader.lazyGetter(this, "ExtensionStorageIDB", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).ExtensionStorageIDB;
+});
+
+/**
+ * The Extension Storage actor.
+ */
+class ExtensionStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "extensionStorage");
+
+ this.addonId = this.storageActor.parentActor.addonId;
+
+ // Retrieve the base moz-extension url for the extension
+ // (and also remove the final '/' from it).
+ this.extensionHostURL = this.getExtensionPolicy().getURL().slice(0, -1);
+
+ // Map<host, ExtensionStorageIDB db connection>
+ // Bug 1542038, 1542039: Each storage area will need its own
+ // dbConnectionForHost, as they each have different storage backends.
+ // Anywhere dbConnectionForHost is used, we need to know the storage
+ // area to access the correct database.
+ this.dbConnectionForHost = new Map();
+
+ this.onExtensionStartup = this.onExtensionStartup.bind(this);
+
+ this.onStorageChange = this.onStorageChange.bind(this);
+ }
+
+ getExtensionPolicy() {
+ return WebExtensionPolicy.getByID(this.addonId);
+ }
+
+ destroy() {
+ ExtensionStorageIDB.removeOnChangedListener(
+ this.addonId,
+ this.onStorageChange
+ );
+ ExtensionParent.apiManager.off("startup", this.onExtensionStartup);
+
+ super.destroy();
+ }
+
+ /**
+ * We need to override this method as we ignore BaseStorageActor's hosts
+ * and only care about the extension host.
+ */
+ async populateStoresForHosts() {
+ // Ensure the actor's target is an extension and it is enabled
+ if (!this.addonId || !this.getExtensionPolicy()) {
+ return;
+ }
+
+ // Subscribe a listener for event notifications from the WE storage API when
+ // storage local data has been changed by the extension, and keep track of the
+ // listener to remove it when the debugger is being disconnected.
+ ExtensionStorageIDB.addOnChangedListener(
+ this.addonId,
+ this.onStorageChange
+ );
+
+ try {
+ // Make sure the extension storage APIs have been loaded,
+ // otherwise the DevTools storage panel would not be updated
+ // automatically when the extension storage data is being changed
+ // if the parent ext-storage.js module wasn't already loaded
+ // (See Bug 1802929).
+ const { extension } = WebExtensionPolicy.getByID(this.addonId);
+ await extension.apiManager.asyncGetAPI("storage", extension);
+ // Also watch for addon reload in order to also do that
+ // on next addon startup, otherwise we may also miss updates
+ ExtensionParent.apiManager.on("startup", this.onExtensionStartup);
+ } catch (e) {
+ console.error(
+ "Exception while trying to initialize webext storage API",
+ e
+ );
+ }
+
+ await this.populateStoresForHost(this.extensionHostURL);
+ }
+
+ /**
+ * AddonManager listener used to force instantiating storage API
+ * implementation in the parent process so that it forward content process
+ * messages to ExtensionStorageIDB.
+ *
+ * Without this, we may miss storage updated after the addon reload.
+ */
+ async onExtensionStartup(_evtName, extension) {
+ if (extension.id != this.addonId) {
+ return;
+ }
+ await extension.apiManager.asyncGetAPI("storage", extension);
+ }
+
+ /**
+ * This method asynchronously reads the storage data for the target extension
+ * and caches this data into this.hostVsStores.
+ * @param {String} host - the hostname for the extension
+ */
+ async populateStoresForHost(host) {
+ if (host !== this.extensionHostURL) {
+ return;
+ }
+
+ const extension = ExtensionProcessScript.getExtensionChild(this.addonId);
+ if (!extension || !extension.hasPermission("storage")) {
+ return;
+ }
+
+ // Make sure storeMap is defined and set in this.hostVsStores before subscribing
+ // a storage onChanged listener in the parent process
+ const storeMap = new Map();
+ this.hostVsStores.set(host, storeMap);
+
+ const storagePrincipal = await this.getStoragePrincipal();
+
+ if (!storagePrincipal) {
+ // This could happen if the extension fails to be migrated to the
+ // IndexedDB backend
+ return;
+ }
+
+ const db = await ExtensionStorageIDB.open(storagePrincipal);
+ this.dbConnectionForHost.set(host, db);
+ const data = await db.get();
+
+ for (const [key, value] of Object.entries(data)) {
+ storeMap.set(key, value);
+ }
+
+ if (this.storageActor.parentActor.fallbackWindow) {
+ // Show the storage actor in the add-on storage inspector even when there
+ // is no extension page currently open
+ // This strategy may need to change depending on the outcome of Bug 1597900
+ const storageData = {};
+ storageData[host] = this.getNamesForHost(host);
+ this.storageActor.update("added", this.typeName, storageData);
+ }
+ }
+ /**
+ * This fires when the extension changes storage data while the storage
+ * inspector is open. Ensures this.hostVsStores stays up-to-date and
+ * passes the changes on to update the client.
+ */
+ onStorageChange(changes) {
+ const host = this.extensionHostURL;
+ const storeMap = this.hostVsStores.get(host);
+
+ function isStructuredCloneHolder(value) {
+ return (
+ value &&
+ typeof value === "object" &&
+ Cu.getClassName(value, true) === "StructuredCloneHolder"
+ );
+ }
+
+ for (const key in changes) {
+ const storageChange = changes[key];
+ let { newValue, oldValue } = storageChange;
+ if (isStructuredCloneHolder(newValue)) {
+ newValue = newValue.deserialize(this);
+ }
+ if (isStructuredCloneHolder(oldValue)) {
+ oldValue = oldValue.deserialize(this);
+ }
+
+ let action;
+ if (typeof newValue === "undefined") {
+ action = "deleted";
+ storeMap.delete(key);
+ } else if (typeof oldValue === "undefined") {
+ action = "added";
+ storeMap.set(key, newValue);
+ } else {
+ action = "changed";
+ storeMap.set(key, newValue);
+ }
+
+ this.storageActor.update(action, this.typeName, { [host]: [key] });
+ }
+ }
+
+ async getStoragePrincipal() {
+ const { extension } = this.getExtensionPolicy();
+ const { backendEnabled, storagePrincipal } =
+ await ExtensionStorageIDB.selectBackend({ extension });
+
+ if (!backendEnabled) {
+ // IDB backend disabled; give up.
+ return null;
+ }
+
+ // Received as a StructuredCloneHolder, so we need to deserialize
+ return storagePrincipal.deserialize(this, true);
+ }
+
+ getValuesForHost(host, name) {
+ const result = [];
+
+ if (!this.hostVsStores.has(host)) {
+ return result;
+ }
+
+ if (name) {
+ return [{ name, value: this.hostVsStores.get(host).get(name) }];
+ }
+
+ for (const [key, value] of Array.from(
+ this.hostVsStores.get(host).entries()
+ )) {
+ result.push({ name: key, value });
+ }
+ return result;
+ }
+
+ /**
+ * Converts a storage item to an "extensionobject" as defined in
+ * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor,
+ * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined`
+ * `item.value`).
+ * @param {Object} item - The storage item to convert
+ * @param {String} item.name - The storage item key
+ * @param {*} item.value - The storage item value
+ * @return {extensionobject}
+ */
+ toStoreObject(item) {
+ if (!item) {
+ return null;
+ }
+
+ let { name, value } = item;
+ const isValueEditable = extensionStorageHelpers.isEditable(value);
+
+ // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings,
+ // and doesn't modify `undefined`.
+ switch (typeof value) {
+ case "bigint":
+ value = `${value.toString()}n`;
+ break;
+ case "string":
+ break;
+ case "undefined":
+ value = "undefined";
+ break;
+ default:
+ value = JSON.stringify(value);
+ if (
+ // can't use `instanceof` across frame boundaries
+ Object.prototype.toString.call(item.value) === "[object Date]"
+ ) {
+ value = JSON.parse(value);
+ }
+ }
+
+ return {
+ name,
+ value: new LongStringActor(this.conn, value),
+ area: "local", // Bug 1542038, 1542039: set the correct storage area
+ isValueEditable,
+ };
+ }
+
+ getFields() {
+ return [
+ { name: "name", editable: false },
+ { name: "value", editable: true },
+ { name: "area", editable: false },
+ { name: "isValueEditable", editable: false, private: true },
+ ];
+ }
+
+ onItemUpdated(action, host, names) {
+ this.storageActor.update(action, this.typeName, {
+ [host]: names,
+ });
+ }
+
+ async editItem({ host, field, items, oldValue }) {
+ const db = this.dbConnectionForHost.get(host);
+ if (!db) {
+ return;
+ }
+
+ const { name, value } = items;
+
+ let parsedValue = parseItemValue(value);
+ if (parsedValue === value) {
+ const { typesFromString } = extensionStorageHelpers;
+ for (const { test, parse } of Object.values(typesFromString)) {
+ if (test(value)) {
+ parsedValue = parse(value);
+ break;
+ }
+ }
+ }
+ const changes = await db.set({ [name]: parsedValue });
+ this.fireOnChangedExtensionEvent(host, changes);
+
+ this.onItemUpdated("changed", host, [name]);
+ }
+
+ async removeItem(host, name) {
+ const db = this.dbConnectionForHost.get(host);
+ if (!db) {
+ return;
+ }
+
+ const changes = await db.remove(name);
+ this.fireOnChangedExtensionEvent(host, changes);
+
+ this.onItemUpdated("deleted", host, [name]);
+ }
+
+ async removeAll(host) {
+ const db = this.dbConnectionForHost.get(host);
+ if (!db) {
+ return;
+ }
+
+ const changes = await db.clear();
+ this.fireOnChangedExtensionEvent(host, changes);
+
+ this.onItemUpdated("cleared", host, []);
+ }
+
+ /**
+ * Let the extension know that storage data has been changed by the user from
+ * the storage inspector.
+ */
+ fireOnChangedExtensionEvent(host, changes) {
+ // Bug 1542038, 1542039: Which message to send depends on the storage area
+ const uuid = new URL(host).host;
+ Services.cpmm.sendAsyncMessage(
+ `Extension:StorageLocalOnChanged:${uuid}`,
+ changes
+ );
+ }
+}
+exports.ExtensionStorageActor = ExtensionStorageActor;
+
+const extensionStorageHelpers = {
+ /**
+ * Editing is supported only for serializable types. Examples of unserializable
+ * types include Map, Set and ArrayBuffer.
+ */
+ isEditable(value) {
+ // Bug 1542038: the managed storage area is never editable
+ for (const { test } of Object.values(this.supportedTypes)) {
+ if (test(value)) {
+ return true;
+ }
+ }
+ return false;
+ },
+ isPrimitive(value) {
+ const primitiveValueTypes = ["string", "number", "boolean"];
+ return primitiveValueTypes.includes(typeof value) || value === null;
+ },
+ isObjectLiteral(value) {
+ return (
+ value &&
+ typeof value === "object" &&
+ Cu.getClassName(value, true) === "Object"
+ );
+ },
+ // Nested arrays or object literals are only editable 2 levels deep
+ isArrayOrObjectLiteralEditable(obj) {
+ const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj);
+ if (
+ topLevelValuesArr.some(
+ value =>
+ !this.isPrimitive(value) &&
+ !Array.isArray(value) &&
+ !this.isObjectLiteral(value)
+ )
+ ) {
+ // At least one value is too complex to parse
+ return false;
+ }
+ const arrayOrObjects = topLevelValuesArr.filter(
+ value => Array.isArray(value) || this.isObjectLiteral(value)
+ );
+ if (arrayOrObjects.length === 0) {
+ // All top level values are primitives
+ return true;
+ }
+
+ // One or more top level values was an array or object literal.
+ // All of these top level values must themselves have only primitive values
+ // for the object to be editable
+ for (const nestedObj of arrayOrObjects) {
+ const secondLevelValuesArr = Array.isArray(nestedObj)
+ ? nestedObj
+ : Object.values(nestedObj);
+ if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) {
+ return false;
+ }
+ }
+ return true;
+ },
+ typesFromString: {
+ // Helper methods to parse string values in editItem
+ jsonifiable: {
+ test(str) {
+ try {
+ JSON.parse(str);
+ } catch (e) {
+ return false;
+ }
+ return true;
+ },
+ parse(str) {
+ return JSON.parse(str);
+ },
+ },
+ },
+ supportedTypes: {
+ // Helper methods to determine the value type of an item in isEditable
+ array: {
+ test(value) {
+ if (Array.isArray(value)) {
+ return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
+ }
+ return false;
+ },
+ },
+ boolean: {
+ test(value) {
+ return typeof value === "boolean";
+ },
+ },
+ null: {
+ test(value) {
+ return value === null;
+ },
+ },
+ number: {
+ test(value) {
+ return typeof value === "number";
+ },
+ },
+ object: {
+ test(value) {
+ if (extensionStorageHelpers.isObjectLiteral(value)) {
+ return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
+ }
+ return false;
+ },
+ },
+ string: {
+ test(value) {
+ return typeof value === "string";
+ },
+ },
+ },
+};
diff --git a/devtools/server/actors/resources/storage/index.js b/devtools/server/actors/resources/storage/index.js
new file mode 100644
index 0000000000..147f9056ea
--- /dev/null
+++ b/devtools/server/actors/resources/storage/index.js
@@ -0,0 +1,404 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const specs = require("resource://devtools/shared/specs/storage.js");
+
+loader.lazyRequireGetter(
+ this,
+ "naturalSortCaseInsensitive",
+ "resource://devtools/shared/natural-sort.js",
+ true
+);
+
+// Maximum number of cookies/local storage key-value-pairs that can be sent
+// over the wire to the client in one request.
+const MAX_STORE_OBJECT_COUNT = 50;
+exports.MAX_STORE_OBJECT_COUNT = MAX_STORE_OBJECT_COUNT;
+
+const DEFAULT_VALUE = "value";
+exports.DEFAULT_VALUE = DEFAULT_VALUE;
+
+// GUID to be used as a separator in compound keys. This must match the same
+// constant in devtools/client/storage/ui.js,
+// devtools/client/storage/test/head.js and
+// devtools/server/tests/browser/head.js
+const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";
+exports.SEPARATOR_GUID = SEPARATOR_GUID;
+
+class BaseStorageActor extends Actor {
+ /**
+ * Base class with the common methods required by all storage actors.
+ *
+ * This base class is missing a couple of required methods that should be
+ * implemented seperately for each actor. They are namely:
+ * - observe : Method which gets triggered on the notification of the watched
+ * topic.
+ * - getNamesForHost : Given a host, get list of all known store names.
+ * - getValuesForHost : Given a host (and optionally a name) get all known
+ * store objects.
+ * - toStoreObject : Given a store object, convert it to the required format
+ * so that it can be transferred over wire.
+ * - populateStoresForHost : Given a host, populate the map of all store
+ * objects for it
+ * - getFields: Given a subType(optional), get an array of objects containing
+ * column field info. The info includes,
+ * "name" is name of colume key.
+ * "editable" is 1 means editable field; 0 means uneditable.
+ *
+ * @param {string} typeName
+ * The typeName of the actor.
+ */
+ constructor(storageActor, typeName) {
+ super(storageActor.conn, specs.childSpecs[typeName]);
+
+ this.storageActor = storageActor;
+
+ // Map keyed by host name whose values are nested Maps.
+ // Nested maps are keyed by store names and values are store values.
+ // Store values are specific to each sub class.
+ // Map(host name => stores <Map(name => values )>)
+ // Map(string => stores <Map(string => any )>)
+ this.hostVsStores = new Map();
+
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroyed = this.onWindowDestroyed.bind(this);
+ this.storageActor.on("window-ready", this.onWindowReady);
+ this.storageActor.on("window-destroyed", this.onWindowDestroyed);
+ }
+
+ destroy() {
+ if (!this.storageActor) {
+ return;
+ }
+
+ this.storageActor.off("window-ready", this.onWindowReady);
+ this.storageActor.off("window-destroyed", this.onWindowDestroyed);
+
+ this.hostVsStores.clear();
+
+ super.destroy();
+
+ this.storageActor = null;
+ }
+
+ /**
+ * Returns a list of currently known hosts for the target window. This list
+ * contains unique hosts from the window + all inner windows. If
+ * this._internalHosts is defined then these will also be added to the list.
+ */
+ get hosts() {
+ const hosts = new Set();
+ for (const { location } of this.storageActor.windows) {
+ const host = this.getHostName(location);
+
+ if (host) {
+ hosts.add(host);
+ }
+ }
+ if (this._internalHosts) {
+ for (const host of this._internalHosts) {
+ hosts.add(host);
+ }
+ }
+ return hosts;
+ }
+
+ /**
+ * Returns all the windows present on the page. Includes main window + inner
+ * iframe windows.
+ */
+ get windows() {
+ return this.storageActor.windows;
+ }
+
+ /**
+ * Converts the window.location object into a URL (e.g. http://domain.com).
+ */
+ getHostName(location) {
+ if (!location) {
+ // Debugging a legacy Firefox extension... no hostname available and no
+ // storage possible.
+ return null;
+ }
+
+ if (this.storageActor.getHostName) {
+ return this.storageActor.getHostName(location);
+ }
+
+ switch (location.protocol) {
+ case "about:":
+ return `${location.protocol}${location.pathname}`;
+ case "chrome:":
+ // chrome: URLs do not support storage of any type.
+ return null;
+ case "data:":
+ // data: URLs do not support storage of any type.
+ return null;
+ case "file:":
+ return `${location.protocol}//${location.pathname}`;
+ case "javascript:":
+ return location.href;
+ case "moz-extension:":
+ return location.origin;
+ case "resource:":
+ return `${location.origin}${location.pathname}`;
+ default:
+ // http: or unknown protocol.
+ return `${location.protocol}//${location.host}`;
+ }
+ }
+
+ /**
+ * Populates a map of known hosts vs a map of stores vs value.
+ */
+ async populateStoresForHosts() {
+ for (const host of this.hosts) {
+ await this.populateStoresForHost(host);
+ }
+ }
+
+ getNamesForHost(host) {
+ return [...this.hostVsStores.get(host).keys()];
+ }
+
+ getValuesForHost(host, name) {
+ if (name) {
+ return [this.hostVsStores.get(host).get(name)];
+ }
+ return [...this.hostVsStores.get(host).values()];
+ }
+
+ getObjectsSize(host, names) {
+ return names.length;
+ }
+
+ /**
+ * When a new window is added to the page. This generally means that a new
+ * iframe is created, or the current window is completely reloaded.
+ *
+ * @param {window} window
+ * The window which was added.
+ */
+ async onWindowReady(window) {
+ if (!this.hostVsStores) {
+ return;
+ }
+ const host = this.getHostName(window.location);
+ if (host && !this.hostVsStores.has(host)) {
+ await this.populateStoresForHost(host, window);
+ if (!this.storageActor) {
+ // The actor might be destroyed during populateStoresForHost.
+ return;
+ }
+
+ const data = {};
+ data[host] = this.getNamesForHost(host);
+ this.storageActor.update("added", this.typeName, data);
+ }
+ }
+
+ /**
+ * When a window is removed from the page. This generally means that an
+ * iframe was removed, or the current window reload is triggered.
+ *
+ * @param {window} window
+ * The window which was removed.
+ * @param {Object} options
+ * @param {Boolean} options.dontCheckHost
+ * If set to true, the function won't check if the host still is in this.hosts.
+ * This is helpful in the case of the StorageActorMock, as the `hosts` getter
+ * uses its `windows` getter, and at this point in time the window which is
+ * going to be destroyed still exists.
+ */
+ onWindowDestroyed(window, { dontCheckHost } = {}) {
+ if (!this.hostVsStores) {
+ return;
+ }
+ if (!window.location) {
+ // Nothing can be done if location object is null
+ return;
+ }
+ const host = this.getHostName(window.location);
+ if (host && (!this.hosts.has(host) || dontCheckHost)) {
+ this.hostVsStores.delete(host);
+ const data = {};
+ data[host] = [];
+ this.storageActor.update("deleted", this.typeName, data);
+ }
+ }
+
+ form() {
+ const hosts = {};
+ for (const host of this.hosts) {
+ hosts[host] = [];
+ }
+
+ return {
+ actor: this.actorID,
+ hosts,
+ traits: this._getTraits(),
+ };
+ }
+
+ // Share getTraits for child classes overriding form()
+ _getTraits() {
+ return {
+ // The supportsXXX traits are not related to backward compatibility
+ // Different storage actor types implement different APIs, the traits
+ // help the client to know what is supported or not.
+ supportsAddItem: typeof this.addItem === "function",
+ // Note: supportsRemoveItem and supportsRemoveAll are always defined
+ // for all actors. See Bug 1655001.
+ supportsRemoveItem: typeof this.removeItem === "function",
+ supportsRemoveAll: typeof this.removeAll === "function",
+ supportsRemoveAllSessionCookies:
+ typeof this.removeAllSessionCookies === "function",
+ };
+ }
+
+ /**
+ * Returns a list of requested store objects. Maximum values returned are
+ * MAX_STORE_OBJECT_COUNT. This method returns paginated values whose
+ * starting index and total size can be controlled via the options object
+ *
+ * @param {string} host
+ * The host name for which the store values are required.
+ * @param {array:string} names
+ * Array containing the names of required store objects. Empty if all
+ * items are required.
+ * @param {object} options
+ * Additional options for the request containing following
+ * properties:
+ * - offset {number} : The begin index of the returned array amongst
+ * the total values
+ * - size {number} : The number of values required.
+ * - sortOn {string} : The values should be sorted on this property.
+ * - index {string} : In case of indexed db, the IDBIndex to be used
+ * for fetching the values.
+ * - sessionString {string} : Client-side value of storage-expires-session
+ * l10n string. Since this function can be called from both
+ * the client and the server, and given that client and
+ * server might have different locales, we can't compute
+ * the localized string directly from here.
+ * @return {object} An object containing following properties:
+ * - offset - The actual offset of the returned array. This might
+ * be different from the requested offset if that was
+ * invalid
+ * - total - The total number of entries possible.
+ * - data - The requested values.
+ */
+ async getStoreObjects(host, names, options = {}) {
+ const offset = options.offset || 0;
+ let size = options.size || MAX_STORE_OBJECT_COUNT;
+ if (size > MAX_STORE_OBJECT_COUNT) {
+ size = MAX_STORE_OBJECT_COUNT;
+ }
+ const sortOn = options.sortOn || "name";
+
+ const toReturn = {
+ offset,
+ total: 0,
+ data: [],
+ };
+
+ let principal = null;
+ if (this.typeName === "indexedDB") {
+ // We only acquire principal when the type of the storage is indexedDB
+ // because the principal only matters the indexedDB.
+ const win = this.storageActor.getWindowFromHost(host);
+ principal = this.getPrincipal(win);
+ }
+
+ if (names) {
+ for (const name of names) {
+ const values = await this.getValuesForHost(
+ host,
+ name,
+ options,
+ this.hostVsStores,
+ principal
+ );
+
+ const { result, objectStores } = values;
+
+ if (result && typeof result.objectsSize !== "undefined") {
+ for (const { key, count } of result.objectsSize) {
+ this.objectsSize[key] = count;
+ }
+ }
+
+ if (result) {
+ toReturn.data.push(...result.data);
+ } else if (objectStores) {
+ toReturn.data.push(...objectStores);
+ } else {
+ toReturn.data.push(...values);
+ }
+ }
+
+ if (this.typeName === "Cache") {
+ // Cache storage contains several items per name but misses a custom
+ // `getObjectsSize` implementation, as implemented for IndexedDB.
+ // See Bug 1745242.
+ toReturn.total = toReturn.data.length;
+ } else {
+ toReturn.total = this.getObjectsSize(host, names, options);
+ }
+ } else {
+ let obj = await this.getValuesForHost(
+ host,
+ undefined,
+ undefined,
+ this.hostVsStores,
+ principal
+ );
+ if (obj.dbs) {
+ obj = obj.dbs;
+ }
+
+ toReturn.total = obj.length;
+ toReturn.data = obj;
+ }
+
+ if (offset > toReturn.total) {
+ // In this case, toReturn.data is an empty array.
+ toReturn.offset = toReturn.total;
+ toReturn.data = [];
+ } else {
+ // We need to use natural sort before slicing.
+ const sorted = toReturn.data.sort((a, b) => {
+ return naturalSortCaseInsensitive(
+ a[sortOn],
+ b[sortOn],
+ options.sessionString
+ );
+ });
+ let sliced;
+ if (this.typeName === "indexedDB") {
+ // indexedDB's getValuesForHost never returns *all* values available but only
+ // a slice, starting at the expected offset. Therefore the result is already
+ // sliced as expected.
+ sliced = sorted;
+ } else {
+ sliced = sorted.slice(offset, offset + size);
+ }
+ toReturn.data = sliced.map(a => this.toStoreObject(a));
+ }
+
+ return toReturn;
+ }
+
+ getPrincipal(win) {
+ if (win) {
+ return win.document.effectiveStoragePrincipal;
+ }
+ // We are running in the browser toolbox and viewing system DBs so we
+ // need to use system principal.
+ return Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal);
+ }
+}
+exports.BaseStorageActor = BaseStorageActor;
diff --git a/devtools/server/actors/resources/storage/indexed-db.js b/devtools/server/actors/resources/storage/indexed-db.js
new file mode 100644
index 0000000000..8ded705c4f
--- /dev/null
+++ b/devtools/server/actors/resources/storage/indexed-db.js
@@ -0,0 +1,984 @@
+/* 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 {
+ BaseStorageActor,
+ MAX_STORE_OBJECT_COUNT,
+ SEPARATOR_GUID,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+// We give this a funny name to avoid confusion with the global
+// indexedDB.
+loader.lazyGetter(this, "indexedDBForStorage", () => {
+ // On xpcshell, we can't instantiate indexedDB without crashing
+ try {
+ const sandbox = Cu.Sandbox(
+ Components.Constructor(
+ "@mozilla.org/systemprincipal;1",
+ "nsIPrincipal"
+ )(),
+ { wantGlobalProperties: ["indexedDB"] }
+ );
+ return sandbox.indexedDB;
+ } catch (e) {
+ return {};
+ }
+});
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+});
+
+/**
+ * An async method equivalent to setTimeout but using Promises
+ *
+ * @param {number} time
+ * The wait time in milliseconds.
+ */
+function sleep(time) {
+ return new Promise(resolve => {
+ setTimeout(() => {
+ resolve(null);
+ }, time);
+ });
+}
+
+const SAFE_HOSTS_PREFIXES_REGEX = /^(about\+|https?\+|file\+|moz-extension\+)/;
+
+// A RegExp for characters that cannot appear in a file/directory name. This is
+// used to sanitize the host name for indexed db to lookup whether the file is
+// present in <profileDir>/storage/default/ location
+const illegalFileNameCharacters = [
+ "[",
+ // Control characters \001 to \036
+ "\\x00-\\x24",
+ // Special characters
+ '/:*?\\"<>|\\\\',
+ "]",
+].join("");
+const ILLEGAL_CHAR_REGEX = new RegExp(illegalFileNameCharacters, "g");
+
+/**
+ * Code related to the Indexed DB actor and front
+ */
+
+// Metadata holder objects for various components of Indexed DB
+
+/**
+ * Meta data object for a particular index in an object store
+ *
+ * @param {IDBIndex} index
+ * The particular index from the object store.
+ */
+function IndexMetadata(index) {
+ this._name = index.name;
+ this._keyPath = index.keyPath;
+ this._unique = index.unique;
+ this._multiEntry = index.multiEntry;
+}
+IndexMetadata.prototype = {
+ toObject() {
+ return {
+ name: this._name,
+ keyPath: this._keyPath,
+ unique: this._unique,
+ multiEntry: this._multiEntry,
+ };
+ },
+};
+
+/**
+ * Meta data object for a particular object store in a db
+ *
+ * @param {IDBObjectStore} objectStore
+ * The particular object store from the db.
+ */
+function ObjectStoreMetadata(objectStore) {
+ this._name = objectStore.name;
+ this._keyPath = objectStore.keyPath;
+ this._autoIncrement = objectStore.autoIncrement;
+ this._indexes = [];
+
+ for (let i = 0; i < objectStore.indexNames.length; i++) {
+ const index = objectStore.index(objectStore.indexNames[i]);
+
+ const newIndex = {
+ keypath: index.keyPath,
+ multiEntry: index.multiEntry,
+ name: index.name,
+ objectStore: {
+ autoIncrement: index.objectStore.autoIncrement,
+ indexNames: [...index.objectStore.indexNames],
+ keyPath: index.objectStore.keyPath,
+ name: index.objectStore.name,
+ },
+ };
+
+ this._indexes.push([newIndex, new IndexMetadata(index)]);
+ }
+}
+ObjectStoreMetadata.prototype = {
+ toObject() {
+ return {
+ name: this._name,
+ keyPath: this._keyPath,
+ autoIncrement: this._autoIncrement,
+ indexes: JSON.stringify(
+ [...this._indexes.values()].map(index => index.toObject())
+ ),
+ };
+ },
+};
+
+/**
+ * Meta data object for a particular indexed db in a host.
+ *
+ * @param {string} origin
+ * The host associated with this indexed db.
+ * @param {IDBDatabase} db
+ * The particular indexed db.
+ * @param {String} storage
+ * Storage type, either "temporary", "default" or "persistent".
+ */
+function DatabaseMetadata(origin, db, storage) {
+ this._origin = origin;
+ this._name = db.name;
+ this._version = db.version;
+ this._objectStores = [];
+ this.storage = storage;
+
+ if (db.objectStoreNames.length) {
+ const transaction = db.transaction(db.objectStoreNames, "readonly");
+
+ for (let i = 0; i < transaction.objectStoreNames.length; i++) {
+ const objectStore = transaction.objectStore(
+ transaction.objectStoreNames[i]
+ );
+ this._objectStores.push([
+ transaction.objectStoreNames[i],
+ new ObjectStoreMetadata(objectStore),
+ ]);
+ }
+ }
+}
+DatabaseMetadata.prototype = {
+ get objectStores() {
+ return this._objectStores;
+ },
+
+ toObject() {
+ return {
+ uniqueKey: `${this._name}${SEPARATOR_GUID}${this.storage}`,
+ name: this._name,
+ storage: this.storage,
+ origin: this._origin,
+ version: this._version,
+ objectStores: this._objectStores.size,
+ };
+ },
+};
+
+class IndexedDBStorageActor extends BaseStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "indexedDB");
+
+ this.objectsSize = {};
+ this.storageActor = storageActor;
+ }
+
+ destroy() {
+ this.objectsSize = null;
+
+ super.destroy();
+ }
+
+ // We need to override this method because of custom, async getHosts method
+ async populateStoresForHosts() {
+ for (const host of await this.getHosts()) {
+ await this.populateStoresForHost(host);
+ }
+ }
+
+ async populateStoresForHost(host) {
+ const storeMap = new Map();
+
+ const win = this.storageActor.getWindowFromHost(host);
+ const principal = this.getPrincipal(win);
+
+ const { names } = await this.getDBNamesForHost(host, principal);
+
+ for (const { name, storage } of names) {
+ let metadata = await this.getDBMetaData(host, principal, name, storage);
+
+ metadata = this.patchMetadataMapsAndProtos(metadata);
+
+ storeMap.set(`${name} (${storage})`, metadata);
+ }
+
+ this.hostVsStores.set(host, storeMap);
+ }
+
+ /**
+ * Returns a list of currently known hosts for the target window. This list
+ * contains unique hosts from the window, all inner windows and all permanent
+ * indexedDB hosts defined inside the browser.
+ */
+ async getHosts() {
+ // Add internal hosts to this._internalHosts, which will be picked up by
+ // the this.hosts getter. Because this.hosts is a property on the default
+ // storage actor and inherited by all storage actors we have to do it this
+ // way.
+ // Only look up internal hosts if we are in the browser toolbox
+ const isBrowserToolbox = this.storageActor.parentActor.isRootActor;
+
+ this._internalHosts = isBrowserToolbox ? await this.getInternalHosts() : [];
+
+ return this.hosts;
+ }
+
+ /**
+ * Remove an indexedDB database from given host with a given name.
+ */
+ async removeDatabase(host, name) {
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return { error: `Window for host ${host} not found` };
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+ return this.removeDB(host, principal, name);
+ }
+
+ async removeAll(host, name) {
+ const [db, store] = JSON.parse(name);
+
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return;
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+ this.clearDBStore(host, principal, db, store);
+ }
+
+ async removeItem(host, name) {
+ const [db, store, id] = JSON.parse(name);
+
+ const win = this.storageActor.getWindowFromHost(host);
+ if (!win) {
+ return;
+ }
+
+ const principal = win.document.effectiveStoragePrincipal;
+ this.removeDBRecord(host, principal, db, store, id);
+ }
+
+ getNamesForHost(host) {
+ const storesForHost = this.hostVsStores.get(host);
+ if (!storesForHost) {
+ return [];
+ }
+
+ const names = [];
+
+ for (const [dbName, { objectStores }] of storesForHost) {
+ if (objectStores.size) {
+ for (const objectStore of objectStores.keys()) {
+ names.push(JSON.stringify([dbName, objectStore]));
+ }
+ } else {
+ names.push(JSON.stringify([dbName]));
+ }
+ }
+
+ return names;
+ }
+
+ /**
+ * Returns the total number of entries for various types of requests to
+ * getStoreObjects for Indexed DB actor.
+ *
+ * @param {string} host
+ * The host for the request.
+ * @param {array:string} names
+ * Array of stringified name objects for indexed db actor.
+ * The request type depends on the length of any parsed entry from this
+ * array. 0 length refers to request for the whole host. 1 length
+ * refers to request for a particular db in the host. 2 length refers
+ * to a particular object store in a db in a host. 3 length refers to
+ * particular items of an object store in a db in a host.
+ * @param {object} options
+ * An options object containing following properties:
+ * - index {string} The IDBIndex for the object store in the db.
+ */
+ getObjectsSize(host, names, options) {
+ // In Indexed DB, we are interested in only the first name, as the pattern
+ // should follow in all entries.
+ const name = names[0];
+ const parsedName = JSON.parse(name);
+
+ if (parsedName.length == 3) {
+ // This is the case where specific entries from an object store were
+ // requested
+ return names.length;
+ } else if (parsedName.length == 2) {
+ // This is the case where all entries from an object store are requested.
+ const index = options.index;
+ const [db, objectStore] = parsedName;
+ if (this.objectsSize[host + db + objectStore + index]) {
+ return this.objectsSize[host + db + objectStore + index];
+ }
+ } else if (parsedName.length == 1) {
+ // This is the case where details of all object stores in a db are
+ // requested.
+ if (
+ this.hostVsStores.has(host) &&
+ this.hostVsStores.get(host).has(parsedName[0])
+ ) {
+ return this.hostVsStores.get(host).get(parsedName[0]).objectStores.size;
+ }
+ } else if (!parsedName || !parsedName.length) {
+ // This is the case were details of all dbs in a host are requested.
+ if (this.hostVsStores.has(host)) {
+ return this.hostVsStores.get(host).size;
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Returns the over-the-wire implementation of the indexed db entity.
+ */
+ toStoreObject(item) {
+ if (!item) {
+ return null;
+ }
+
+ if ("indexes" in item) {
+ // Object store meta data
+ return {
+ objectStore: item.name,
+ keyPath: item.keyPath,
+ autoIncrement: item.autoIncrement,
+ indexes: item.indexes,
+ };
+ }
+ if ("objectStores" in item) {
+ // DB meta data
+ return {
+ uniqueKey: `${item.name} (${item.storage})`,
+ db: item.name,
+ storage: item.storage,
+ origin: item.origin,
+ version: item.version,
+ objectStores: item.objectStores,
+ };
+ }
+
+ const value = JSON.stringify(item.value);
+
+ // Indexed db entry
+ return {
+ name: item.name,
+ value: new LongStringActor(this.conn, value),
+ };
+ }
+
+ form() {
+ const hosts = {};
+ for (const host of this.hosts) {
+ hosts[host] = this.getNamesForHost(host);
+ }
+
+ return {
+ actor: this.actorID,
+ hosts,
+ traits: this._getTraits(),
+ };
+ }
+
+ onItemUpdated(action, host, path) {
+ dump(" IDX.onItemUpdated(" + action + " - " + host + " - " + path + "\n");
+ // Database was removed, remove it from stores map
+ if (action === "deleted" && path.length === 1) {
+ if (this.hostVsStores.has(host)) {
+ this.hostVsStores.get(host).delete(path[0]);
+ }
+ }
+
+ this.storageActor.update(action, "indexedDB", {
+ [host]: [JSON.stringify(path)],
+ });
+ }
+
+ async getFields(subType) {
+ switch (subType) {
+ // Detail of database
+ case "database":
+ return [
+ { name: "objectStore", editable: false },
+ { name: "keyPath", editable: false },
+ { name: "autoIncrement", editable: false },
+ { name: "indexes", editable: false },
+ ];
+
+ // Detail of object store
+ case "object store":
+ return [
+ { name: "name", editable: false },
+ { name: "value", editable: false },
+ ];
+
+ // Detail of indexedDB for one origin
+ default:
+ return [
+ { name: "uniqueKey", editable: false, private: true },
+ { name: "db", editable: false },
+ { name: "storage", editable: false },
+ { name: "origin", editable: false },
+ { name: "version", editable: false },
+ { name: "objectStores", editable: false },
+ ];
+ }
+ }
+
+ /**
+ * Fetches and stores all the metadata information for the given database
+ * `name` for the given `host` with its `principal`. The stored metadata
+ * information is of `DatabaseMetadata` type.
+ */
+ async getDBMetaData(host, principal, name, storage) {
+ const request = this.openWithPrincipal(principal, name, storage);
+ return new Promise(resolve => {
+ request.onsuccess = event => {
+ const db = event.target.result;
+ const dbData = new DatabaseMetadata(host, db, storage);
+ db.close();
+
+ resolve(dbData);
+ };
+ request.onerror = ({ target }) => {
+ console.error(
+ `Error opening indexeddb database ${name} for host ${host}`,
+ target.error
+ );
+ resolve(null);
+ };
+ });
+ }
+
+ splitNameAndStorage(name) {
+ const lastOpenBracketIndex = name.lastIndexOf("(");
+ const lastCloseBracketIndex = name.lastIndexOf(")");
+ const delta = lastCloseBracketIndex - lastOpenBracketIndex - 1;
+
+ const storage = name.substr(lastOpenBracketIndex + 1, delta);
+
+ name = name.substr(0, lastOpenBracketIndex - 1);
+
+ return { storage, name };
+ }
+
+ /**
+ * Get all "internal" hosts. Internal hosts are database namespaces used by
+ * the browser.
+ */
+ async getInternalHosts() {
+ const profileDir = PathUtils.profileDir;
+ const storagePath = PathUtils.join(profileDir, "storage", "permanent");
+ const children = await IOUtils.getChildren(storagePath);
+ const hosts = [];
+
+ for (const path of children) {
+ const exists = await IOUtils.exists(path);
+ if (!exists) {
+ continue;
+ }
+
+ const stats = await IOUtils.stat(path);
+ if (
+ stats.type === "directory" &&
+ !SAFE_HOSTS_PREFIXES_REGEX.test(stats.path)
+ ) {
+ const basename = PathUtils.filename(path);
+ hosts.push(basename);
+ }
+ }
+
+ return hosts;
+ }
+
+ /**
+ * Opens an indexed db connection for the given `principal` and
+ * database `name`.
+ */
+ openWithPrincipal(principal, name, storage) {
+ return indexedDBForStorage.openForPrincipal(principal, name, {
+ storage,
+ });
+ }
+
+ async removeDB(host, principal, dbName) {
+ const result = new Promise(resolve => {
+ const { name, storage } = this.splitNameAndStorage(dbName);
+ const request = indexedDBForStorage.deleteForPrincipal(principal, name, {
+ storage,
+ });
+
+ request.onsuccess = () => {
+ resolve({});
+ this.onItemUpdated("deleted", host, [dbName]);
+ };
+
+ request.onblocked = () => {
+ console.warn(
+ `Deleting indexedDB database ${name} for host ${host} is blocked`
+ );
+ resolve({ blocked: true });
+ };
+
+ request.onerror = () => {
+ const { error } = request;
+ console.warn(
+ `Error deleting indexedDB database ${name} for host ${host}: ${error}`
+ );
+ resolve({ error: error.message });
+ };
+
+ // If the database is blocked repeatedly, the onblocked event will not
+ // be fired again. To avoid waiting forever, report as blocked if nothing
+ // else happens after 3 seconds.
+ setTimeout(() => resolve({ blocked: true }), 3000);
+ });
+
+ return result;
+ }
+
+ async removeDBRecord(host, principal, dbName, storeName, id) {
+ let db;
+ const { name, storage } = this.splitNameAndStorage(dbName);
+
+ try {
+ db = await new Promise((resolve, reject) => {
+ const request = this.openWithPrincipal(principal, name, storage);
+ request.onsuccess = ev => resolve(ev.target.result);
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ const transaction = db.transaction(storeName, "readwrite");
+ const store = transaction.objectStore(storeName);
+
+ await new Promise((resolve, reject) => {
+ const request = store.delete(id);
+ request.onsuccess = () => resolve();
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ this.onItemUpdated("deleted", host, [dbName, storeName, id]);
+ } catch (error) {
+ const recordPath = [dbName, storeName, id].join("/");
+ console.error(
+ `Failed to delete indexedDB record: ${recordPath}: ${error}`
+ );
+ }
+
+ if (db) {
+ db.close();
+ }
+
+ return null;
+ }
+
+ async clearDBStore(host, principal, dbName, storeName) {
+ let db;
+ const { name, storage } = this.splitNameAndStorage(dbName);
+
+ try {
+ db = await new Promise((resolve, reject) => {
+ const request = this.openWithPrincipal(principal, name, storage);
+ request.onsuccess = ev => resolve(ev.target.result);
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ const transaction = db.transaction(storeName, "readwrite");
+ const store = transaction.objectStore(storeName);
+
+ await new Promise((resolve, reject) => {
+ const request = store.clear();
+ request.onsuccess = () => resolve();
+ request.onerror = ev => reject(ev.target.error);
+ });
+
+ this.onItemUpdated("cleared", host, [dbName, storeName]);
+ } catch (error) {
+ const storePath = [dbName, storeName].join("/");
+ console.error(`Failed to clear indexedDB store: ${storePath}: ${error}`);
+ }
+
+ if (db) {
+ db.close();
+ }
+
+ return null;
+ }
+
+ /**
+ * Fetches all the databases and their metadata for the given `host`.
+ */
+ async getDBNamesForHost(host, principal) {
+ const sanitizedHost = this.getSanitizedHost(host) + principal.originSuffix;
+ const profileDir = PathUtils.profileDir;
+ const storagePath = PathUtils.join(profileDir, "storage");
+ const files = [];
+ const names = [];
+
+ // We expect sqlite DB paths to look something like this:
+ // - PathToProfileDir/storage/default/http+++www.example.com/
+ // idb/1556056096MeysDaabta.sqlite
+ // - PathToProfileDir/storage/permanent/http+++www.example.com/
+ // idb/1556056096MeysDaabta.sqlite
+ // - PathToProfileDir/storage/temporary/http+++www.example.com/
+ // idb/1556056096MeysDaabta.sqlite
+ // The subdirectory inside the storage folder is determined by the storage
+ // type:
+ // - default: { storage: "default" } or not specified.
+ // - permanent: { storage: "persistent" }.
+ // - temporary: { storage: "temporary" }.
+ const sqliteFiles = await this.findSqlitePathsForHost(
+ storagePath,
+ sanitizedHost
+ );
+
+ for (const file of sqliteFiles) {
+ const splitPath = PathUtils.split(file);
+ const idbIndex = splitPath.indexOf("idb");
+ const storage = splitPath[idbIndex - 2];
+ const relative = file.substr(profileDir.length + 1);
+
+ files.push({
+ file: relative,
+ storage: storage === "permanent" ? "persistent" : storage,
+ });
+ }
+
+ if (files.length) {
+ for (const { file, storage } of files) {
+ const name = await this.getNameFromDatabaseFile(file);
+ if (name) {
+ names.push({
+ name,
+ storage,
+ });
+ }
+ }
+ }
+
+ return { names };
+ }
+
+ /**
+ * Find all SQLite files that hold IndexedDB data for a host, such as:
+ * storage/temporary/http+++www.example.com/idb/1556056096MeysDaabta.sqlite
+ */
+ async findSqlitePathsForHost(storagePath, sanitizedHost) {
+ const sqlitePaths = [];
+ const idbPaths = await this.findIDBPathsForHost(storagePath, sanitizedHost);
+ for (const idbPath of idbPaths) {
+ const children = await IOUtils.getChildren(idbPath);
+
+ for (const path of children) {
+ const exists = await IOUtils.exists(path);
+ if (!exists) {
+ continue;
+ }
+
+ const stats = await IOUtils.stat(path);
+ if (stats.type !== "directory" && stats.path.endsWith(".sqlite")) {
+ sqlitePaths.push(path);
+ }
+ }
+ }
+ return sqlitePaths;
+ }
+
+ /**
+ * Find all paths that hold IndexedDB data for a host, such as:
+ * storage/temporary/http+++www.example.com/idb
+ */
+ async findIDBPathsForHost(storagePath, sanitizedHost) {
+ const idbPaths = [];
+ const typePaths = await this.findStorageTypePaths(storagePath);
+ for (const typePath of typePaths) {
+ const idbPath = PathUtils.join(typePath, sanitizedHost, "idb");
+ if (await IOUtils.exists(idbPath)) {
+ idbPaths.push(idbPath);
+ }
+ }
+ return idbPaths;
+ }
+
+ /**
+ * Find all the storage types, such as "default", "permanent", or "temporary".
+ * These names have changed over time, so it seems simpler to look through all
+ * types that currently exist in the profile.
+ */
+ async findStorageTypePaths(storagePath) {
+ const children = await IOUtils.getChildren(storagePath);
+ const typePaths = [];
+
+ for (const path of children) {
+ const exists = await IOUtils.exists(path);
+ if (!exists) {
+ continue;
+ }
+
+ const stats = await IOUtils.stat(path);
+ if (stats.type === "directory") {
+ typePaths.push(path);
+ }
+ }
+
+ return typePaths;
+ }
+
+ /**
+ * Removes any illegal characters from the host name to make it a valid file
+ * name.
+ */
+ getSanitizedHost(host) {
+ if (host.startsWith("about:")) {
+ host = "moz-safe-" + host;
+ }
+ return host.replace(ILLEGAL_CHAR_REGEX, "+");
+ }
+
+ /**
+ * Retrieves the proper indexed db database name from the provided .sqlite
+ * file location.
+ */
+ async getNameFromDatabaseFile(path) {
+ let connection = null;
+ let retryCount = 0;
+
+ // Content pages might be having an open transaction for the same indexed db
+ // which this sqlite file belongs to. In that case, sqlite.openConnection
+ // will throw. Thus we retry for some time to see if lock is removed.
+ while (!connection && retryCount++ < 25) {
+ try {
+ connection = await lazy.Sqlite.openConnection({ path });
+ } catch (ex) {
+ // Continuously retrying is overkill. Waiting for 100ms before next try
+ await sleep(100);
+ }
+ }
+
+ if (!connection) {
+ return null;
+ }
+
+ const rows = await connection.execute("SELECT name FROM database");
+ if (rows.length != 1) {
+ return null;
+ }
+
+ const name = rows[0].getResultByName("name");
+
+ await connection.close();
+
+ return name;
+ }
+
+ async getValuesForHost(
+ host,
+ name = "null",
+ options,
+ hostVsStores,
+ principal
+ ) {
+ name = JSON.parse(name);
+ if (!name || !name.length) {
+ // This means that details about the db in this particular host are
+ // requested.
+ const dbs = [];
+ if (hostVsStores.has(host)) {
+ for (let [, db] of hostVsStores.get(host)) {
+ db = this.patchMetadataMapsAndProtos(db);
+ dbs.push(db.toObject());
+ }
+ }
+ return { dbs };
+ }
+
+ const [db2, objectStore, id] = name;
+ if (!objectStore) {
+ // This means that details about all the object stores in this db are
+ // requested.
+ const objectStores = [];
+ if (hostVsStores.has(host) && hostVsStores.get(host).has(db2)) {
+ let db = hostVsStores.get(host).get(db2);
+
+ db = this.patchMetadataMapsAndProtos(db);
+
+ const objectStores2 = db.objectStores;
+
+ for (const objectStore2 of objectStores2) {
+ objectStores.push(objectStore2[1].toObject());
+ }
+ }
+ return {
+ objectStores,
+ };
+ }
+ // Get either all entries from the object store, or a particular id
+ const storage = hostVsStores.get(host).get(db2).storage;
+ const result = await this.getObjectStoreData(
+ host,
+ principal,
+ db2,
+ storage,
+ {
+ objectStore,
+ id,
+ index: options.index,
+ offset: options.offset,
+ size: options.size,
+ }
+ );
+ return { result };
+ }
+
+ /**
+ * Returns requested entries (or at most MAX_STORE_OBJECT_COUNT) from a particular
+ * objectStore from the db in the given host.
+ *
+ * @param {string} host
+ * The given host.
+ * @param {nsIPrincipal} principal
+ * The principal of the given document.
+ * @param {string} dbName
+ * The name of the indexed db from the above host.
+ * @param {String} storage
+ * Storage type, either "temporary", "default" or "persistent".
+ * @param {Object} requestOptions
+ * An object in the following format:
+ * {
+ * objectStore: The name of the object store from the above db,
+ * id: Id of the requested entry from the above object
+ * store. null if all entries from the above object
+ * store are requested,
+ * index: Name of the IDBIndex to be iterated on while fetching
+ * entries. null or "name" if no index is to be
+ * iterated,
+ * offset: offset of the entries to be fetched,
+ * size: The intended size of the entries to be fetched
+ * }
+ */
+ getObjectStoreData(host, principal, dbName, storage, requestOptions) {
+ const { name } = this.splitNameAndStorage(dbName);
+ const request = this.openWithPrincipal(principal, name, storage);
+
+ return new Promise((resolve, reject) => {
+ let { objectStore, id, index, offset, size } = requestOptions;
+ const data = [];
+ let db;
+
+ if (!size || size > MAX_STORE_OBJECT_COUNT) {
+ size = MAX_STORE_OBJECT_COUNT;
+ }
+
+ request.onsuccess = event => {
+ db = event.target.result;
+
+ const transaction = db.transaction(objectStore, "readonly");
+ let source = transaction.objectStore(objectStore);
+ if (index && index != "name") {
+ source = source.index(index);
+ }
+
+ source.count().onsuccess = event2 => {
+ const objectsSize = [];
+ const count = event2.target.result;
+ objectsSize.push({
+ key: host + dbName + objectStore + index,
+ count,
+ });
+
+ if (!offset) {
+ offset = 0;
+ } else if (offset > count) {
+ db.close();
+ resolve([]);
+ return;
+ }
+
+ if (id) {
+ source.get(id).onsuccess = event3 => {
+ db.close();
+ resolve([{ name: id, value: event3.target.result }]);
+ };
+ } else {
+ source.openCursor().onsuccess = event4 => {
+ const cursor = event4.target.result;
+
+ if (!cursor || data.length >= size) {
+ db.close();
+ resolve({
+ data,
+ objectsSize,
+ });
+ return;
+ }
+ if (offset-- <= 0) {
+ data.push({ name: cursor.key, value: cursor.value });
+ }
+ cursor.continue();
+ };
+ }
+ };
+ };
+
+ request.onerror = () => {
+ db.close();
+ resolve([]);
+ };
+ });
+ }
+
+ /**
+ * When indexedDB metadata is parsed to and from JSON then the object's
+ * prototype is dropped and any Maps are changed to arrays of arrays. This
+ * method is used to repair the prototypes and fix any broken Maps.
+ */
+ patchMetadataMapsAndProtos(metadata) {
+ const md = Object.create(DatabaseMetadata.prototype);
+ Object.assign(md, metadata);
+
+ md._objectStores = new Map(metadata._objectStores);
+
+ for (const [name, store] of md._objectStores) {
+ const obj = Object.create(ObjectStoreMetadata.prototype);
+ Object.assign(obj, store);
+
+ md._objectStores.set(name, obj);
+
+ if (typeof store._indexes.length !== "undefined") {
+ obj._indexes = new Map(store._indexes);
+ }
+
+ for (const [name2, value] of obj._indexes) {
+ const obj2 = Object.create(IndexMetadata.prototype);
+ Object.assign(obj2, value);
+
+ obj._indexes.set(name2, obj2);
+ }
+ }
+
+ return md;
+ }
+}
+exports.IndexedDBStorageActor = IndexedDBStorageActor;
diff --git a/devtools/server/actors/resources/storage/local-and-session-storage.js b/devtools/server/actors/resources/storage/local-and-session-storage.js
new file mode 100644
index 0000000000..ba0f006d22
--- /dev/null
+++ b/devtools/server/actors/resources/storage/local-and-session-storage.js
@@ -0,0 +1,200 @@
+/* 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 {
+ BaseStorageActor,
+ DEFAULT_VALUE,
+} = require("resource://devtools/server/actors/resources/storage/index.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+class LocalOrSessionStorageActor extends BaseStorageActor {
+ constructor(storageActor, typeName) {
+ super(storageActor, typeName);
+
+ Services.obs.addObserver(this, "dom-storage2-changed");
+ Services.obs.addObserver(this, "dom-private-storage2-changed");
+ }
+
+ destroy() {
+ if (this.isDestroyed()) {
+ return;
+ }
+ Services.obs.removeObserver(this, "dom-storage2-changed");
+ Services.obs.removeObserver(this, "dom-private-storage2-changed");
+
+ super.destroy();
+ }
+
+ getNamesForHost(host) {
+ const storage = this.hostVsStores.get(host);
+ return storage ? Object.keys(storage) : [];
+ }
+
+ getValuesForHost(host, name) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return [];
+ }
+ if (name) {
+ const value = storage ? storage.getItem(name) : null;
+ return [{ name, value }];
+ }
+ if (!storage) {
+ return [];
+ }
+
+ // local and session storage cannot be iterated over using Object.keys()
+ // because it skips keys that are duplicated on the prototype
+ // e.g. "key", "getKeys" so we need to gather the real keys using the
+ // storage.key() function.
+ const storageArray = [];
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ storageArray.push({
+ name: key,
+ value: storage.getItem(key),
+ });
+ }
+ return storageArray;
+ }
+
+ // We need to override this method as populateStoresForHost expect the window object
+ populateStoresForHosts() {
+ this.hostVsStores = new Map();
+ for (const window of this.windows) {
+ const host = this.getHostName(window.location);
+ if (host) {
+ this.populateStoresForHost(host, window);
+ }
+ }
+ }
+
+ populateStoresForHost(host, window) {
+ try {
+ this.hostVsStores.set(host, window[this.typeName]);
+ } catch (ex) {
+ console.warn(
+ `Failed to enumerate ${this.typeName} for host ${host}: ${ex}`
+ );
+ }
+ }
+
+ async getFields() {
+ return [
+ { name: "name", editable: true },
+ { name: "value", editable: true },
+ ];
+ }
+
+ async addItem(guid, host) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.setItem(guid, DEFAULT_VALUE);
+ }
+
+ /**
+ * Edit localStorage or sessionStorage fields.
+ *
+ * @param {Object} data
+ * See editCookie() for format details.
+ */
+ async editItem({ host, field, oldValue, items }) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+
+ if (field === "name") {
+ storage.removeItem(oldValue);
+ }
+
+ storage.setItem(items.name, items.value);
+ }
+
+ async removeItem(host, name) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.removeItem(name);
+ }
+
+ async removeAll(host) {
+ const storage = this.hostVsStores.get(host);
+ if (!storage) {
+ return;
+ }
+ storage.clear();
+ }
+
+ observe(subject, topic, data) {
+ if (
+ (topic != "dom-storage2-changed" &&
+ topic != "dom-private-storage2-changed") ||
+ data != this.typeName
+ ) {
+ return null;
+ }
+
+ const host = this.getSchemaAndHost(subject.url);
+
+ if (!this.hostVsStores.has(host)) {
+ return null;
+ }
+
+ let action = "changed";
+ if (subject.key == null) {
+ return this.storageActor.update("cleared", this.typeName, [host]);
+ } else if (subject.oldValue == null) {
+ action = "added";
+ } else if (subject.newValue == null) {
+ action = "deleted";
+ }
+ const updateData = {};
+ updateData[host] = [subject.key];
+ return this.storageActor.update(action, this.typeName, updateData);
+ }
+
+ /**
+ * Given a url, correctly determine its protocol + hostname part.
+ */
+ getSchemaAndHost(url) {
+ const uri = Services.io.newURI(url);
+ if (!uri.host) {
+ return uri.spec;
+ }
+ return uri.scheme + "://" + uri.hostPort;
+ }
+
+ toStoreObject(item) {
+ if (!item) {
+ return null;
+ }
+
+ return {
+ name: item.name,
+ value: new LongStringActor(this.conn, item.value || ""),
+ };
+ }
+}
+
+class LocalStorageActor extends LocalOrSessionStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "localStorage");
+ }
+}
+exports.LocalStorageActor = LocalStorageActor;
+
+class SessionStorageActor extends LocalOrSessionStorageActor {
+ constructor(storageActor) {
+ super(storageActor, "sessionStorage");
+ }
+}
+exports.SessionStorageActor = SessionStorageActor;
diff --git a/devtools/server/actors/resources/storage/moz.build b/devtools/server/actors/resources/storage/moz.build
new file mode 100644
index 0000000000..1615254759
--- /dev/null
+++ b/devtools/server/actors/resources/storage/moz.build
@@ -0,0 +1,17 @@
+# -*- 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(
+ "cache.js",
+ "cookies.js",
+ "extension-storage.js",
+ "index.js",
+ "indexed-db.js",
+ "local-and-session-storage.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Storage Inspector")
diff --git a/devtools/server/actors/resources/stylesheets.js b/devtools/server/actors/resources/stylesheets.js
new file mode 100644
index 0000000000..9e107e305b
--- /dev/null
+++ b/devtools/server/actors/resources/stylesheets.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 {
+ TYPES: { STYLESHEET },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/shared/inspector/css-logic.js"
+);
+
+class StyleSheetWatcher {
+ constructor() {
+ this._onApplicableStylesheetAdded =
+ this._onApplicableStylesheetAdded.bind(this);
+ this._onStylesheetUpdated = this._onStylesheetUpdated.bind(this);
+ this._onStylesheetRemoved = this._onStylesheetRemoved.bind(this);
+ }
+
+ /**
+ * Start watching for all stylesheets related to a given Target Actor.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe css changes.
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable, onUpdated, onDestroyed }) {
+ this._targetActor = targetActor;
+ this._onAvailable = onAvailable;
+ this._onUpdated = onUpdated;
+ this._onDestroyed = onDestroyed;
+
+ this._styleSheetsManager = targetActor.getStyleSheetsManager();
+
+ // watch will call onAvailable for already existing stylesheets
+ await this._styleSheetsManager.watch({
+ onAvailable: this._onApplicableStylesheetAdded,
+ onUpdated: this._onStylesheetUpdated,
+ onDestroyed: this._onStylesheetRemoved,
+ });
+ }
+
+ _onApplicableStylesheetAdded(styleSheetData) {
+ return this._notifyResourcesAvailable([styleSheetData]);
+ }
+
+ _onStylesheetUpdated({ resourceId, updateKind, updates = {} }) {
+ this._notifyResourceUpdated(resourceId, updateKind, updates);
+ }
+
+ _onStylesheetRemoved({ resourceId }) {
+ return this._notifyResourcesDestroyed(resourceId);
+ }
+
+ async _toResource(
+ styleSheet,
+ { isCreatedByDevTools = false, fileName = null, resourceId } = {}
+ ) {
+ const { atRules, ruleCount } =
+ this._styleSheetsManager.getStyleSheetRuleCountAndAtRules(styleSheet);
+
+ const resource = {
+ resourceId,
+ resourceType: STYLESHEET,
+ disabled: styleSheet.disabled,
+ constructed: styleSheet.constructed,
+ fileName,
+ href: styleSheet.href,
+ isNew: isCreatedByDevTools,
+ atRules,
+ nodeHref: this._styleSheetsManager.getNodeHref(styleSheet),
+ ruleCount,
+ sourceMapBaseURL:
+ this._styleSheetsManager.getSourcemapBaseURL(styleSheet),
+ sourceMapURL: styleSheet.sourceMapURL,
+ styleSheetIndex: this._styleSheetsManager.getStyleSheetIndex(resourceId),
+ system: CssLogic.isAgentStylesheet(styleSheet),
+ title: styleSheet.title,
+ };
+
+ return resource;
+ }
+
+ async _notifyResourcesAvailable(styleSheets) {
+ const resources = await Promise.all(
+ styleSheets.map(async ({ resourceId, styleSheet, creationData }) => {
+ const resource = await this._toResource(styleSheet, {
+ resourceId,
+ isCreatedByDevTools: creationData?.isCreatedByDevTools,
+ fileName: creationData?.fileName,
+ });
+
+ return resource;
+ })
+ );
+
+ await this._onAvailable(resources);
+ }
+
+ _notifyResourceUpdated(
+ resourceId,
+ updateType,
+ { resourceUpdates, nestedResourceUpdates, event }
+ ) {
+ this._onUpdated([
+ {
+ browsingContextID: this._targetActor.browsingContextID,
+ innerWindowId: this._targetActor.innerWindowId,
+ resourceType: STYLESHEET,
+ resourceId,
+ updateType,
+ resourceUpdates,
+ nestedResourceUpdates,
+ event,
+ },
+ ]);
+ }
+
+ _notifyResourcesDestroyed(resourceId) {
+ this._onDestroyed([
+ {
+ resourceType: STYLESHEET,
+ resourceId,
+ },
+ ]);
+ }
+
+ destroy() {
+ this._styleSheetsManager.unwatch({
+ onAvailable: this._onApplicableStylesheetAdded,
+ onUpdated: this._onStylesheetUpdated,
+ onDestroyed: this._onStylesheetRemoved,
+ });
+ }
+}
+
+module.exports = StyleSheetWatcher;
diff --git a/devtools/server/actors/resources/thread-states.js b/devtools/server/actors/resources/thread-states.js
new file mode 100644
index 0000000000..9ac79088d2
--- /dev/null
+++ b/devtools/server/actors/resources/thread-states.js
@@ -0,0 +1,136 @@
+/* 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: { THREAD_STATE },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const {
+ PAUSE_REASONS,
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+// Possible values of breakpoint's resource's `state` attribute
+const STATES = {
+ PAUSED: "paused",
+ RESUMED: "resumed",
+};
+
+/**
+ * Emit THREAD_STATE resources, which is emitted each time the target's thread pauses or resumes.
+ * So that there is two distinct values for this resource: pauses and resumes.
+ * These values are distinguished by `state` attribute which can be either "paused" or "resumed".
+ *
+ * Resume events, won't expose any other attribute other than `resourceType` and `state`.
+ *
+ * Pause events will expose the following attributes:
+ * - why {Object}: Description of why the thread pauses. See ThreadActor's PAUSE_REASONS definition for more information.
+ * - frame {Object}: Description of the frame where we just paused. This is a FrameActor's form.
+ */
+class BreakpointWatcher {
+ constructor() {
+ this.onPaused = this.onPaused.bind(this);
+ this.onResumed = this.onResumed.bind(this);
+ }
+
+ /**
+ * Start watching for state changes of the thread actor.
+ * This will notify whenever the thread actor pause and resume.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe breakpoints
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ const { threadActor } = targetActor;
+ this.threadActor = threadActor;
+ this.onAvailable = onAvailable;
+
+ // If this watcher is created during target creation, attach the thread actor automatically.
+ // Otherwise it would not pause on anything (especially debugger statements).
+ // However, do not attach the thread actor for Workers. They use a codepath
+ // which releases the worker on `attach`. For them, the client will call `attach`. (bug 1691986)
+ const isTargetCreation = this.threadActor.state == THREAD_STATES.DETACHED;
+ if (isTargetCreation && !targetActor.targetType.endsWith("worker")) {
+ await this.threadActor.attach({});
+ }
+
+ this.isInterrupted = false;
+
+ threadActor.on("paused", this.onPaused);
+ threadActor.on("resumed", this.onResumed);
+
+ // For top-level targets, the thread actor may have been attached by the frontend
+ // on toolbox opening, and we start observing for thread state updates much later.
+ // In which case, the thread actor may already be paused and we handle this here.
+ // It will also occurs for all other targets once bug 1681698 lands,
+ // as the thread actor will be initialized before the target starts loading.
+ // And it will occur for all targets once bug 1686748 lands.
+ //
+ // Note that we have to check if we have a "lastPausedPacket",
+ // because the thread Actor is immediately set as being paused,
+ // but the pause packet is built asynchronously and available slightly later.
+ // If the "lastPausedPacket" is null, while the thread actor is paused,
+ // it is fine to ignore as the "paused" event will be fire later.
+ if (threadActor.isPaused() && threadActor.lastPausedPacket()) {
+ this.onPaused(threadActor.lastPausedPacket());
+ }
+ }
+
+ /**
+ * Stop watching for breakpoints
+ */
+ destroy() {
+ this.threadActor.off("paused", this.onPaused);
+ this.threadActor.off("resumed", this.onResumed);
+ }
+
+ onPaused(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 === PAUSE_REASONS.INTERRUPTED && !why.onNext) {
+ this.isInterrupted = true;
+ return;
+ }
+
+ // Ignore attached events because they are not useful to the user.
+ if (why.type == PAUSE_REASONS.ALREADY_PAUSED) {
+ return;
+ }
+
+ this.onAvailable([
+ {
+ resourceType: THREAD_STATE,
+ state: STATES.PAUSED,
+ why,
+ frame: packet.frame.form(),
+ },
+ ]);
+ }
+
+ onResumed(packet) {
+ // NOTE: resumed events are suppressed while interrupted
+ // to prevent unintentional behavior.
+ if (this.isInterrupted) {
+ this.isInterrupted = false;
+ return;
+ }
+
+ this.onAvailable([
+ {
+ resourceType: THREAD_STATE,
+ state: STATES.RESUMED,
+ },
+ ]);
+ }
+}
+
+module.exports = BreakpointWatcher;
diff --git a/devtools/server/actors/resources/utils/content-process-storage.js b/devtools/server/actors/resources/utils/content-process-storage.js
new file mode 100644
index 0000000000..7e126ce3f7
--- /dev/null
+++ b/devtools/server/actors/resources/utils/content-process-storage.js
@@ -0,0 +1,453 @@
+/* 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 lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ getAddonIdForWindowGlobal:
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
+});
+
+// ms of delay to throttle updates
+const BATCH_DELAY = 200;
+
+// Filters "stores-update" response to only include events for
+// the storage type we desire
+function getFilteredStorageEvents(updates, storageType) {
+ const filteredUpdate = Object.create(null);
+
+ // updateType will be "added", "changed", or "deleted"
+ for (const updateType in updates) {
+ if (updates[updateType][storageType]) {
+ if (!filteredUpdate[updateType]) {
+ filteredUpdate[updateType] = {};
+ }
+ filteredUpdate[updateType][storageType] =
+ updates[updateType][storageType];
+ }
+ }
+
+ return Object.keys(filteredUpdate).length ? filteredUpdate : null;
+}
+
+class ContentProcessStorage {
+ constructor(ActorConstructor, storageKey, storageType) {
+ this.ActorConstructor = ActorConstructor;
+ this.storageKey = storageKey;
+ this.storageType = storageType;
+
+ this.onStoresUpdate = this.onStoresUpdate.bind(this);
+ this.onStoresCleared = this.onStoresCleared.bind(this);
+ }
+
+ async watch(targetActor, { onAvailable }) {
+ const storageActor = new StorageActorMock(targetActor);
+ this.storageActor = storageActor;
+ this.actor = new this.ActorConstructor(storageActor);
+
+ // Some storage types require to prelist their stores
+ await this.actor.populateStoresForHosts();
+
+ // We have to manage the actor manually, because ResourceCommand doesn't
+ // use the protocol.js specification.
+ // resource-available-form is typed as "json"
+ // So that we have to manually handle stuff that would normally be
+ // automagically done by procotol.js
+ // 1) Manage the actor in order to have an actorID on it
+ targetActor.manage(this.actor);
+ // 2) Convert to JSON "form"
+ const form = this.actor.form();
+
+ // NOTE: this is hoisted, so the `update` method above may use it.
+ const storage = form;
+
+ // All resources should have a resourceType, resourceId and resourceKey
+ // attributes, so available/updated/destroyed callbacks work properly.
+ storage.resourceType = this.storageType;
+ storage.resourceId = this.storageType;
+ storage.resourceKey = this.storageKey;
+
+ onAvailable([storage]);
+
+ // Maps global events from `storageActor` shared for all storage-types,
+ // down to storage-type's specific actor `storage`.
+ storageActor.on("stores-update", this.onStoresUpdate);
+
+ // When a store gets cleared
+ storageActor.on("stores-cleared", this.onStoresCleared);
+ }
+
+ onStoresUpdate(response) {
+ response = getFilteredStorageEvents(response, this.storageKey);
+ if (!response) {
+ return;
+ }
+ this.actor.emit("single-store-update", {
+ changed: response.changed,
+ added: response.added,
+ deleted: response.deleted,
+ });
+ }
+
+ onStoresCleared(response) {
+ const cleared = response[this.storageKey];
+
+ if (!cleared) {
+ return;
+ }
+
+ this.actor.emit("single-store-cleared", {
+ clearedHostsOrPaths: cleared,
+ });
+ }
+
+ destroy() {
+ this.actor?.destroy();
+ this.actor = null;
+ if (this.storageActor) {
+ this.storageActor.on("stores-update", this.onStoresUpdate);
+ this.storageActor.on("stores-cleared", this.onStoresCleared);
+ this.storageActor.destroy();
+ this.storageActor = null;
+ }
+ }
+}
+
+module.exports = ContentProcessStorage;
+
+// This class mocks what used to be implement in devtools/server/actors/storage.js: StorageActor
+// But without being a protocol.js actor, nor implement any RDP method/event.
+// An instance of this class is passed to each storage type actor and named `storageActor`.
+// Once we implement all storage type in watcher classes, we can get rid of the original
+// StorageActor in devtools/server/actors/storage.js
+class StorageActorMock extends EventEmitter {
+ constructor(targetActor) {
+ super();
+ // Storage classes fetch conn from storageActor
+ this.conn = targetActor.conn;
+ this.targetActor = targetActor;
+
+ this.childWindowPool = new Set();
+
+ // Fetch all the inner iframe windows in this tab.
+ this.fetchChildWindows(this.targetActor.docShell);
+
+ // Notifications that help us keep track of newly added windows and windows
+ // that got removed
+ Services.obs.addObserver(this, "content-document-global-created");
+ Services.obs.addObserver(this, "inner-window-destroyed");
+ this.onPageChange = this.onPageChange.bind(this);
+
+ const handler = targetActor.chromeEventHandler;
+ handler.addEventListener("pageshow", this.onPageChange, true);
+ handler.addEventListener("pagehide", this.onPageChange, true);
+
+ this.destroyed = false;
+ this.boundUpdate = {};
+ }
+
+ destroy() {
+ clearTimeout(this.batchTimer);
+ this.batchTimer = null;
+ // Remove observers
+ Services.obs.removeObserver(this, "content-document-global-created");
+ Services.obs.removeObserver(this, "inner-window-destroyed");
+ this.destroyed = true;
+ if (this.targetActor.browser) {
+ this.targetActor.browser.removeEventListener(
+ "pageshow",
+ this.onPageChange,
+ true
+ );
+ this.targetActor.browser.removeEventListener(
+ "pagehide",
+ this.onPageChange,
+ true
+ );
+ }
+ this.childWindowPool.clear();
+
+ this.childWindowPool = null;
+ this.targetActor = null;
+ this.boundUpdate = null;
+ }
+
+ get window() {
+ return this.targetActor.window;
+ }
+
+ get document() {
+ return this.targetActor.window.document;
+ }
+
+ get windows() {
+ return this.childWindowPool;
+ }
+
+ /**
+ * Given a docshell, recursively find out all the child windows from it.
+ *
+ * @param {nsIDocShell} item
+ * The docshell from which all inner windows need to be extracted.
+ */
+ fetchChildWindows(item) {
+ const docShell = item
+ .QueryInterface(Ci.nsIDocShell)
+ .QueryInterface(Ci.nsIDocShellTreeItem);
+ if (!docShell.docViewer) {
+ return null;
+ }
+ const window = docShell.docViewer.DOMDocument.defaultView;
+ if (window.location.href == "about:blank") {
+ // Skip out about:blank windows as Gecko creates them multiple times while
+ // creating any global.
+ return null;
+ }
+ if (!this.isIncludedInTopLevelWindow(window)) {
+ return null;
+ }
+ this.childWindowPool.add(window);
+ for (let i = 0; i < docShell.childCount; i++) {
+ const child = docShell.getChildAt(i);
+ this.fetchChildWindows(child);
+ }
+ return null;
+ }
+
+ isIncludedInTargetExtension(subject) {
+ const addonId = lazy.getAddonIdForWindowGlobal(subject.windowGlobalChild);
+ return addonId && addonId === this.targetActor.addonId;
+ }
+
+ isIncludedInTopLevelWindow(window) {
+ return this.targetActor.windows.includes(window);
+ }
+
+ getWindowFromInnerWindowID(innerID) {
+ innerID = innerID.QueryInterface(Ci.nsISupportsPRUint64).data;
+ for (const win of this.childWindowPool.values()) {
+ const id = win.windowGlobalChild.innerWindowId;
+ if (id == innerID) {
+ return win;
+ }
+ }
+ return null;
+ }
+
+ getWindowFromHost(host) {
+ for (const win of this.childWindowPool.values()) {
+ const origin = win.document.nodePrincipal.originNoSuffix;
+ const url = win.document.URL;
+ if (origin === host || url === host) {
+ return win;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Event handler for any docshell update. This lets us figure out whenever
+ * any new window is added, or an existing window is removed.
+ */
+ observe(subject, topic) {
+ if (
+ subject.location &&
+ (!subject.location.href || subject.location.href == "about:blank")
+ ) {
+ return null;
+ }
+
+ // We don't want to try to find a top level window for an extension page, as
+ // in many cases (e.g. background page), it is not loaded in a tab, and
+ // 'isIncludedInTopLevelWindow' throws an error
+ if (
+ topic == "content-document-global-created" &&
+ (this.isIncludedInTargetExtension(subject) ||
+ this.isIncludedInTopLevelWindow(subject))
+ ) {
+ this.childWindowPool.add(subject);
+ this.emit("window-ready", subject);
+ } else if (topic == "inner-window-destroyed") {
+ const window = this.getWindowFromInnerWindowID(subject);
+ if (window) {
+ this.childWindowPool.delete(window);
+ this.emit("window-destroyed", window);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Called on "pageshow" or "pagehide" event on the chromeEventHandler of
+ * current tab.
+ *
+ * @param {event} The event object passed to the handler. We are using these
+ * three properties from the event:
+ * - target {document} The document corresponding to the event.
+ * - type {string} Name of the event - "pageshow" or "pagehide".
+ * - persisted {boolean} true if there was no
+ * "content-document-global-created" notification along
+ * this event.
+ */
+ onPageChange({ target, type, persisted }) {
+ if (this.destroyed) {
+ return;
+ }
+
+ const window = target.defaultView;
+
+ if (type == "pagehide" && this.childWindowPool.delete(window)) {
+ this.emit("window-destroyed", window);
+ } else if (
+ type == "pageshow" &&
+ persisted &&
+ window.location.href &&
+ window.location.href != "about:blank" &&
+ this.isIncludedInTopLevelWindow(window)
+ ) {
+ this.childWindowPool.add(window);
+ this.emit("window-ready", window);
+ }
+ }
+
+ /**
+ * This method is called by the registered storage types so as to tell the
+ * Storage Actor that there are some changes in the stores. Storage Actor then
+ * notifies the client front about these changes at regular (BATCH_DELAY)
+ * interval.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor in which this change has occurred.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the host in which this change happened and
+ * [<store_namesX] is an array of the names of the changed store objects.
+ * Pass an empty array if the host itself was affected: either completely
+ * removed or cleared.
+ */
+ // eslint-disable-next-line complexity
+ update(action, storeType, data) {
+ if (action == "cleared") {
+ this.emit("stores-cleared", { [storeType]: data });
+ return null;
+ }
+
+ if (this.batchTimer) {
+ clearTimeout(this.batchTimer);
+ }
+ if (!this.boundUpdate[action]) {
+ this.boundUpdate[action] = {};
+ }
+ if (!this.boundUpdate[action][storeType]) {
+ this.boundUpdate[action][storeType] = {};
+ }
+ for (const host in data) {
+ if (!this.boundUpdate[action][storeType][host]) {
+ this.boundUpdate[action][storeType][host] = [];
+ }
+ for (const name of data[host]) {
+ if (!this.boundUpdate[action][storeType][host].includes(name)) {
+ this.boundUpdate[action][storeType][host].push(name);
+ }
+ }
+ }
+ if (action == "added") {
+ // If the same store name was previously deleted or changed, but now is
+ // added somehow, dont send the deleted or changed update.
+ this.removeNamesFromUpdateList("deleted", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+ } else if (
+ action == "changed" &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType]
+ ) {
+ // If something got added and changed at the same time, then remove those
+ // items from changed instead.
+ this.removeNamesFromUpdateList(
+ "changed",
+ storeType,
+ this.boundUpdate.added[storeType]
+ );
+ } else if (action == "deleted") {
+ // If any item got delete, or a host got delete, no point in sending
+ // added or changed update
+ this.removeNamesFromUpdateList("added", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+
+ for (const host in data) {
+ if (
+ !data[host].length &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType] &&
+ this.boundUpdate.added[storeType][host]
+ ) {
+ delete this.boundUpdate.added[storeType][host];
+ }
+ if (
+ !data[host].length &&
+ this.boundUpdate.changed &&
+ this.boundUpdate.changed[storeType] &&
+ this.boundUpdate.changed[storeType][host]
+ ) {
+ delete this.boundUpdate.changed[storeType][host];
+ }
+ }
+ }
+
+ this.batchTimer = setTimeout(() => {
+ clearTimeout(this.batchTimer);
+ this.emit("stores-update", this.boundUpdate);
+ this.boundUpdate = {};
+ }, BATCH_DELAY);
+
+ return null;
+ }
+
+ /**
+ * This method removes data from the this.boundUpdate object in the same
+ * manner like this.update() adds data to it.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor for which you want to remove the updates data.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the hosts which you want to remove and
+ * [<store_namesX] is an array of the names of the store objects.
+ */
+ removeNamesFromUpdateList(action, storeType, data) {
+ for (const host in data) {
+ if (
+ this.boundUpdate[action] &&
+ this.boundUpdate[action][storeType] &&
+ this.boundUpdate[action][storeType][host]
+ ) {
+ for (const name in data[host]) {
+ const index = this.boundUpdate[action][storeType][host].indexOf(name);
+ if (index > -1) {
+ this.boundUpdate[action][storeType][host].splice(index, 1);
+ }
+ }
+ if (!this.boundUpdate[action][storeType][host].length) {
+ delete this.boundUpdate[action][storeType][host];
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/devtools/server/actors/resources/utils/moz.build b/devtools/server/actors/resources/utils/moz.build
new file mode 100644
index 0000000000..0e6f9d1baa
--- /dev/null
+++ b/devtools/server/actors/resources/utils/moz.build
@@ -0,0 +1,14 @@
+# -*- 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(
+ "content-process-storage.js",
+ "nsi-console-listener-watcher.js",
+ "parent-process-storage.js",
+)
+
+with Files("nsi-console-listener-watcher.js"):
+ BUG_COMPONENT = ("DevTools", "Console")
diff --git a/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.js
new file mode 100644
index 0000000000..8d1ed43612
--- /dev/null
+++ b/devtools/server/actors/resources/utils/nsi-console-listener-watcher.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";
+
+const {
+ createStringGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+
+const {
+ getActorIdForInternalSourceId,
+} = require("resource://devtools/server/actors/utils/dbg-source.js");
+
+class nsIConsoleListenerWatcher {
+ /**
+ * Start watching for all messages related to a given Target Actor.
+ * This will notify about existing messages, as well as those created in the future.
+ *
+ * @param TargetActor targetActor
+ * The target actor from which we should observe messages
+ * @param Object options
+ * Dictionary object with following attributes:
+ * - onAvailable: mandatory function
+ * This will be called for each resource.
+ */
+ async watch(targetActor, { onAvailable }) {
+ if (!this.shouldHandleTarget(targetActor)) {
+ return;
+ }
+
+ let latestRetrievedCachedMessageTimestamp = -1;
+
+ // Create the consoleListener.
+ const listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIConsoleListener"]),
+ observe: message => {
+ if (
+ message.microSecondTimeStamp <= latestRetrievedCachedMessageTimestamp
+ ) {
+ return;
+ }
+
+ if (!this.shouldHandleMessage(targetActor, message)) {
+ return;
+ }
+
+ onAvailable([this.buildResource(targetActor, message)]);
+ },
+ };
+
+ // Retrieve the cached messages and get the last cached message timestamp before
+ // registering the listener, so we can ignore messages we'd be notified about but that
+ // were already retrieved in the cache.
+ const cachedMessages = Services.console.getMessageArray() || [];
+ if (cachedMessages.length) {
+ latestRetrievedCachedMessageTimestamp =
+ cachedMessages.at(-1).microSecondTimeStamp;
+ }
+
+ Services.console.registerListener(listener);
+ this.listener = listener;
+
+ // Remove unwanted cache messages and send an array of resources.
+ const messages = [];
+ for (const message of cachedMessages) {
+ if (!this.shouldHandleMessage(targetActor, message, true)) {
+ continue;
+ }
+
+ messages.push(this.buildResource(targetActor, message));
+ }
+ onAvailable(messages);
+ }
+
+ /**
+ * Return false if the watcher shouldn't be created.
+ *
+ * @param {TargetActor} targetActor
+ * @return {Boolean}
+ */
+ shouldHandleTarget(targetActor) {
+ return true;
+ }
+
+ /**
+ * Return true if you want the passed message to be handled by the watcher. This should
+ * be implemented on the child class.
+ *
+ * @param {TargetActor} targetActor
+ * @param {nsIScriptError|nsIConsoleMessage} message
+ * @return {Boolean}
+ */
+ shouldHandleMessage(targetActor, message) {
+ throw new Error(
+ "'shouldHandleMessage' should be implemented in the class that extends nsIConsoleListenerWatcher"
+ );
+ }
+
+ /**
+ * Prepare the resource to be sent to the client. This should be implemented on the
+ * child class.
+ *
+ * @param targetActor
+ * @param nsIScriptError|nsIConsoleMessage message
+ * @return object
+ * The object you can send to the remote client.
+ */
+ buildResource(targetActor, message) {
+ throw new Error(
+ "'buildResource' should be implemented in the class that extends nsIConsoleListenerWatcher"
+ );
+ }
+
+ /**
+ * Prepare a SavedFrame stack to be sent to the client.
+ *
+ * @param {TargetActor} targetActor
+ * @param {SavedFrame} errorStack
+ * Stack for an error we need to send to the client.
+ * @return object
+ * The object you can send to the remote client.
+ */
+ prepareStackForRemote(targetActor, errorStack) {
+ // Convert stack objects to the JSON attributes expected by client code
+ // Bug 1348885: If the global from which this error came from has been
+ // nuked, stack is going to be a dead wrapper.
+ if (!errorStack || (Cu && Cu.isDeadWrapper(errorStack))) {
+ return null;
+ }
+ const stack = [];
+ let s = errorStack;
+ while (s) {
+ stack.push({
+ filename: s.source,
+ sourceId: getActorIdForInternalSourceId(targetActor, s.sourceId),
+ lineNumber: s.line,
+ columnNumber: s.column,
+ functionName: s.functionDisplayName,
+ asyncCause: s.asyncCause ? s.asyncCause : undefined,
+ });
+ s = s.parent || s.asyncParent;
+ }
+ return stack;
+ }
+
+ /**
+ * Prepare error notes to be sent to the client.
+ *
+ * @param {TargetActor} targetActor
+ * @param {nsIArray<nsIScriptErrorNote>} errorNotes
+ * @return object
+ * The object you can send to the remote client.
+ */
+ prepareNotesForRemote(targetActor, errorNotes) {
+ if (!errorNotes?.length) {
+ return null;
+ }
+
+ const notes = [];
+ for (let i = 0, len = errorNotes.length; i < len; i++) {
+ const note = errorNotes.queryElementAt(i, Ci.nsIScriptErrorNote);
+ notes.push({
+ messageBody: createStringGrip(targetActor, note.errorMessage),
+ frame: {
+ source: note.sourceName,
+ sourceId: getActorIdForInternalSourceId(targetActor, note.sourceId),
+ line: note.lineNumber,
+ column: note.columnNumber,
+ },
+ });
+ }
+ return notes;
+ }
+
+ isProcessTarget(targetActor) {
+ const { typeName } = targetActor;
+ return (
+ typeName === "parentProcessTarget" || typeName === "contentProcessTarget"
+ );
+ }
+
+ /**
+ * Stop watching for messages.
+ */
+ destroy() {
+ if (this.listener) {
+ Services.console.unregisterListener(this.listener);
+ }
+ }
+}
+module.exports = nsIConsoleListenerWatcher;
diff --git a/devtools/server/actors/resources/utils/parent-process-storage.js b/devtools/server/actors/resources/utils/parent-process-storage.js
new file mode 100644
index 0000000000..423d13b6b5
--- /dev/null
+++ b/devtools/server/actors/resources/utils/parent-process-storage.js
@@ -0,0 +1,580 @@
+/* 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 { isWindowGlobalPartOfContext } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs"
+);
+
+// ms of delay to throttle updates
+const BATCH_DELAY = 200;
+
+// Filters "stores-update" response to only include events for
+// the storage type we desire
+function getFilteredStorageEvents(updates, storageType) {
+ const filteredUpdate = Object.create(null);
+
+ // updateType will be "added", "changed", or "deleted"
+ for (const updateType in updates) {
+ if (updates[updateType][storageType]) {
+ if (!filteredUpdate[updateType]) {
+ filteredUpdate[updateType] = {};
+ }
+ filteredUpdate[updateType][storageType] =
+ updates[updateType][storageType];
+ }
+ }
+
+ return Object.keys(filteredUpdate).length ? filteredUpdate : null;
+}
+
+class ParentProcessStorage {
+ constructor(ActorConstructor, storageKey, storageType) {
+ this.ActorConstructor = ActorConstructor;
+ this.storageKey = storageKey;
+ this.storageType = storageType;
+
+ this.onStoresUpdate = this.onStoresUpdate.bind(this);
+ this.onStoresCleared = this.onStoresCleared.bind(this);
+
+ this.observe = this.observe.bind(this);
+ // Notifications that help us keep track of newly added windows and windows
+ // that got removed
+ Services.obs.addObserver(this, "window-global-created");
+ Services.obs.addObserver(this, "window-global-destroyed");
+
+ // bfcacheInParent is only enabled when fission is enabled
+ // and when Session History In Parent is enabled. (all three modes should now enabled all together)
+ loader.lazyGetter(
+ this,
+ "isBfcacheInParentEnabled",
+ () =>
+ Services.appinfo.sessionHistoryInParent &&
+ Services.prefs.getBoolPref("fission.bfcacheInParent", false)
+ );
+ }
+
+ async watch(watcherActor, { onAvailable }) {
+ this.watcherActor = watcherActor;
+ this.onAvailable = onAvailable;
+
+ // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled,
+ // we're not getting a the window-global-created events.
+ // In such case, the watcher emits specific events that we can use instead.
+ this._offPageShow = watcherActor.on(
+ "bf-cache-navigation-pageshow",
+ ({ windowGlobal }) => this._onNewWindowGlobal(windowGlobal, true)
+ );
+
+ if (watcherActor.sessionContext.type == "browser-element") {
+ const { browsingContext, innerWindowID: innerWindowId } =
+ watcherActor.browserElement;
+ await this._spawnActor(browsingContext.id, innerWindowId);
+ } else if (watcherActor.sessionContext.type == "webextension") {
+ const { addonBrowsingContextID, addonInnerWindowId } =
+ watcherActor.sessionContext;
+ await this._spawnActor(addonBrowsingContextID, addonInnerWindowId);
+ } else if (watcherActor.sessionContext.type == "all") {
+ const parentProcessTargetActor =
+ this.watcherActor.getTargetActorInParentProcess();
+ const { browsingContextID, innerWindowId } =
+ parentProcessTargetActor.form();
+ await this._spawnActor(browsingContextID, innerWindowId);
+ } else {
+ throw new Error(
+ "Unsupported session context type=" + watcherActor.sessionContext.type
+ );
+ }
+ }
+
+ onStoresUpdate(response) {
+ response = getFilteredStorageEvents(response, this.storageKey);
+ if (!response) {
+ return;
+ }
+ this.actor.emit("single-store-update", {
+ changed: response.changed,
+ added: response.added,
+ deleted: response.deleted,
+ });
+ }
+
+ onStoresCleared(response) {
+ const cleared = response[this.storageKey];
+
+ if (!cleared) {
+ return;
+ }
+
+ this.actor.emit("single-store-cleared", {
+ clearedHostsOrPaths: cleared,
+ });
+ }
+
+ destroy() {
+ // Remove observers
+ Services.obs.removeObserver(this, "window-global-created");
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ this._offPageShow();
+ this._cleanActor();
+ }
+
+ async _spawnActor(browsingContextID, innerWindowId) {
+ const storageActor = new StorageActorMock(this.watcherActor);
+ this.storageActor = storageActor;
+ this.actor = new this.ActorConstructor(storageActor);
+
+ // Some storage types require to prelist their stores
+ try {
+ await this.actor.populateStoresForHosts();
+ } catch (e) {
+ // It can happen that the actor gets destroyed while populateStoresForHosts is being
+ // executed.
+ if (this.actor) {
+ throw e;
+ }
+ }
+
+ // If the actor was destroyed, we don't need to go further.
+ if (!this.actor) {
+ return;
+ }
+
+ // We have to manage the actor manually, because ResourceCommand doesn't
+ // use the protocol.js specification.
+ // resource-available-form is typed as "json"
+ // So that we have to manually handle stuff that would normally be
+ // automagically done by procotol.js
+ // 1) Manage the actor in order to have an actorID on it
+ this.watcherActor.manage(this.actor);
+ // 2) Convert to JSON "form"
+ const storage = this.actor.form();
+
+ // All resources should have a resourceType, resourceId and resourceKey
+ // attributes, so available/updated/destroyed callbacks work properly.
+ storage.resourceType = this.storageType;
+ storage.resourceId = `${this.storageType}-${innerWindowId}`;
+ storage.resourceKey = this.storageKey;
+ // NOTE: the resource command needs this attribute
+ storage.browsingContextID = browsingContextID;
+
+ this.onAvailable([storage]);
+
+ // Maps global events from `storageActor` shared for all storage-types,
+ // down to storage-type's specific actor `storage`.
+ storageActor.on("stores-update", this.onStoresUpdate);
+
+ // When a store gets cleared
+ storageActor.on("stores-cleared", this.onStoresCleared);
+ }
+
+ _cleanActor() {
+ this.actor?.destroy();
+ this.actor = null;
+ if (this.storageActor) {
+ this.storageActor.off("stores-update", this.onStoresUpdate);
+ this.storageActor.off("stores-cleared", this.onStoresCleared);
+ this.storageActor.destroy();
+ this.storageActor = null;
+ }
+ }
+
+ /**
+ * Event handler for any docshell update. This lets us figure out whenever
+ * any new window is added, or an existing window is removed.
+ */
+ observe(subject, topic) {
+ if (topic === "window-global-created") {
+ this._onNewWindowGlobal(subject);
+ }
+ }
+
+ /**
+ * Handle WindowGlobal received via:
+ * - <window-global-created> (to cover regular navigations, with brand new documents)
+ * - <bf-cache-navigation-pageshow> (to cover history navications)
+ *
+ * @param {WindowGlobal} windowGlobal
+ * @param {Boolean} isBfCacheNavigation
+ */
+ async _onNewWindowGlobal(windowGlobal, isBfCacheNavigation) {
+ // Only process WindowGlobals which are related to the debugged scope.
+ if (
+ !isWindowGlobalPartOfContext(
+ windowGlobal,
+ this.watcherActor.sessionContext,
+ { acceptNoWindowGlobal: true, acceptSameProcessIframes: true }
+ )
+ ) {
+ return;
+ }
+
+ // Ignore about:blank
+ if (windowGlobal.documentURI.displaySpec === "about:blank") {
+ return;
+ }
+
+ // Only process top BrowsingContext (ignore same-process iframe ones)
+ const isTopContext =
+ windowGlobal.browsingContext.top == windowGlobal.browsingContext;
+ if (!isTopContext) {
+ return;
+ }
+
+ // We only want to spawn a new StorageActor if a new target is being created, i.e.
+ // - target switching is enabled and we're notified about a new top-level window global,
+ // via window-global-created
+ // - target switching is enabled OR bfCacheInParent is enabled, and a bfcache navigation
+ // is performed (See handling of "pageshow" event in DevToolsFrameChild)
+ const isNewTargetBeingCreated =
+ this.watcherActor.sessionContext.isServerTargetSwitchingEnabled ||
+ (isBfCacheNavigation && this.isBfcacheInParentEnabled);
+
+ if (!isNewTargetBeingCreated) {
+ return;
+ }
+
+ // When server side target switching is enabled, we replace the StorageActor
+ // with a new one.
+ // On the frontend, the navigation will destroy the previous target, which
+ // will destroy the previous storage front, so we must notify about a new one.
+
+ // When we are target switching we keep the storage watcher, so we need
+ // to send a new resource to the client.
+ // However, we must ensure that we do this when the new target is
+ // already available, so we check innerWindowId to do it.
+ await new Promise(resolve => {
+ const listener = targetActorForm => {
+ if (targetActorForm.innerWindowId != windowGlobal.innerWindowId) {
+ return;
+ }
+ this.watcherActor.off("target-available-form", listener);
+ resolve();
+ };
+ this.watcherActor.on("target-available-form", listener);
+ });
+
+ this._cleanActor();
+ this._spawnActor(
+ windowGlobal.browsingContext.id,
+ windowGlobal.innerWindowId
+ );
+ }
+}
+
+module.exports = ParentProcessStorage;
+
+class StorageActorMock extends EventEmitter {
+ constructor(watcherActor) {
+ super();
+
+ this.conn = watcherActor.conn;
+ this.watcherActor = watcherActor;
+
+ this.boundUpdate = {};
+
+ // Notifications that help us keep track of newly added windows and windows
+ // that got removed
+ this.observe = this.observe.bind(this);
+ Services.obs.addObserver(this, "window-global-created");
+ Services.obs.addObserver(this, "window-global-destroyed");
+
+ // When doing a bfcache navigation with Fission disabled or with Fission + bfCacheInParent enabled,
+ // we're not getting a the window-global-created/window-global-destroyed events.
+ // In such case, the watcher emits specific events that we can use as equivalent to
+ // window-global-created/window-global-destroyed.
+ // We only need to react to those events here if target switching is not enabled; when
+ // it is enabled, ParentProcessStorage will spawn a whole new actor which will allow
+ // the client to get the information it needs.
+ if (!this.watcherActor.sessionContext.isServerTargetSwitchingEnabled) {
+ this._offPageShow = watcherActor.on(
+ "bf-cache-navigation-pageshow",
+ ({ windowGlobal }) => {
+ // if a new target is created in the content process as a result of the bfcache
+ // navigation, we don't need to emit window-ready as a new StorageActorMock will
+ // be created by ParentProcessStorage.
+ // When server targets are disabled, this only happens when bfcache in parent is enabled.
+ if (this.isBfcacheInParentEnabled) {
+ return;
+ }
+ const windowMock = { location: windowGlobal.documentURI };
+ this.emit("window-ready", windowMock);
+ }
+ );
+
+ this._offPageHide = watcherActor.on(
+ "bf-cache-navigation-pagehide",
+ ({ windowGlobal }) => {
+ const windowMock = { location: windowGlobal.documentURI };
+ // The listener of this events usually check that there are no other windows
+ // with the same host before notifying the client that it can remove it from
+ // the UI. The windows are retrieved from the `windows` getter, and in this case
+ // we still have a reference to the window we're navigating away from.
+ // We pass a `dontCheckHost` parameter alongside the window-destroyed event to
+ // always notify the client.
+ this.emit("window-destroyed", windowMock, { dontCheckHost: true });
+ }
+ );
+ }
+ }
+
+ destroy() {
+ // clear update throttle timeout
+ clearTimeout(this.batchTimer);
+ this.batchTimer = null;
+ // Remove observers
+ Services.obs.removeObserver(this, "window-global-created");
+ Services.obs.removeObserver(this, "window-global-destroyed");
+ if (this._offPageShow) {
+ this._offPageShow();
+ }
+ if (this._offPageHide) {
+ this._offPageHide();
+ }
+ }
+
+ get windows() {
+ return (
+ this.watcherActor
+ .getAllBrowsingContexts({
+ acceptSameProcessIframes: true,
+ })
+ .map(x => {
+ const uri = x.currentWindowGlobal.documentURI;
+ return { location: uri };
+ })
+ // NOTE: we are removing about:blank because we might get them for iframes
+ // whose src attribute has not been set yet.
+ .filter(x => x.location.displaySpec !== "about:blank")
+ );
+ }
+
+ // NOTE: this uri argument is not a real window.Location, but the
+ // `currentWindowGlobal.documentURI` object passed from `windows` getter.
+ getHostName(uri) {
+ switch (uri.scheme) {
+ case "about":
+ case "file":
+ case "javascript":
+ case "resource":
+ return uri.displaySpec;
+ case "moz-extension":
+ case "http":
+ case "https":
+ return uri.prePath;
+ default:
+ // chrome: and data: do not support storage
+ return null;
+ }
+ }
+
+ getWindowFromHost(host) {
+ const hostBrowsingContext = this.watcherActor
+ .getAllBrowsingContexts({ acceptSameProcessIframes: true })
+ .find(x => {
+ const hostName = this.getHostName(x.currentWindowGlobal.documentURI);
+ return hostName === host;
+ });
+ // In case of WebExtension or BrowserToolbox, we may pass privileged hosts
+ // which don't relate to any particular window.
+ // Like "indexeddb+++fx-devtools" or "chrome".
+ // (callsites of this method are used to handle null returned values)
+ if (!hostBrowsingContext) {
+ return null;
+ }
+
+ const principal =
+ hostBrowsingContext.currentWindowGlobal.documentStoragePrincipal;
+
+ return { document: { effectiveStoragePrincipal: principal } };
+ }
+
+ get parentActor() {
+ return {
+ isRootActor: this.watcherActor.sessionContext.type == "all",
+ addonId: this.watcherActor.sessionContext.addonId,
+ };
+ }
+
+ /**
+ * Event handler for any docshell update. This lets us figure out whenever
+ * any new window is added, or an existing window is removed.
+ */
+ async observe(windowGlobal, topic) {
+ // Only process WindowGlobals which are related to the debugged scope.
+ if (
+ !isWindowGlobalPartOfContext(
+ windowGlobal,
+ this.watcherActor.sessionContext,
+ { acceptNoWindowGlobal: true, acceptSameProcessIframes: true }
+ )
+ ) {
+ return;
+ }
+
+ // Ignore about:blank
+ if (windowGlobal.documentURI.displaySpec === "about:blank") {
+ return;
+ }
+
+ // Only notify about remote iframe windows when JSWindowActor based targets are enabled
+ // We will create a new StorageActor for the top level tab documents when server side target
+ // switching is enabled
+ const isTopContext =
+ windowGlobal.browsingContext.top == windowGlobal.browsingContext;
+ if (
+ isTopContext &&
+ this.watcherActor.sessionContext.isServerTargetSwitchingEnabled
+ ) {
+ return;
+ }
+
+ // emit window-wready and window-destroyed events when needed
+ const windowMock = { location: windowGlobal.documentURI };
+ if (topic === "window-global-created") {
+ this.emit("window-ready", windowMock);
+ } else if (topic === "window-global-destroyed") {
+ this.emit("window-destroyed", windowMock);
+ }
+ }
+
+ /**
+ * This method is called by the registered storage types so as to tell the
+ * Storage Actor that there are some changes in the stores. Storage Actor then
+ * notifies the client front about these changes at regular (BATCH_DELAY)
+ * interval.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor in which this change has occurred.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the host in which this change happened and
+ * [<store_namesX] is an array of the names of the changed store objects.
+ * Pass an empty array if the host itself was affected: either completely
+ * removed or cleared.
+ */
+ // eslint-disable-next-line complexity
+ update(action, storeType, data) {
+ if (action == "cleared") {
+ this.emit("stores-cleared", { [storeType]: data });
+ return null;
+ }
+
+ if (this.batchTimer) {
+ clearTimeout(this.batchTimer);
+ }
+ if (!this.boundUpdate[action]) {
+ this.boundUpdate[action] = {};
+ }
+ if (!this.boundUpdate[action][storeType]) {
+ this.boundUpdate[action][storeType] = {};
+ }
+ for (const host in data) {
+ if (!this.boundUpdate[action][storeType][host]) {
+ this.boundUpdate[action][storeType][host] = [];
+ }
+ for (const name of data[host]) {
+ if (!this.boundUpdate[action][storeType][host].includes(name)) {
+ this.boundUpdate[action][storeType][host].push(name);
+ }
+ }
+ }
+ if (action == "added") {
+ // If the same store name was previously deleted or changed, but now is
+ // added somehow, dont send the deleted or changed update.
+ this.removeNamesFromUpdateList("deleted", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+ } else if (
+ action == "changed" &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType]
+ ) {
+ // If something got added and changed at the same time, then remove those
+ // items from changed instead.
+ this.removeNamesFromUpdateList(
+ "changed",
+ storeType,
+ this.boundUpdate.added[storeType]
+ );
+ } else if (action == "deleted") {
+ // If any item got delete, or a host got delete, no point in sending
+ // added or changed update
+ this.removeNamesFromUpdateList("added", storeType, data);
+ this.removeNamesFromUpdateList("changed", storeType, data);
+
+ for (const host in data) {
+ if (
+ !data[host].length &&
+ this.boundUpdate.added &&
+ this.boundUpdate.added[storeType] &&
+ this.boundUpdate.added[storeType][host]
+ ) {
+ delete this.boundUpdate.added[storeType][host];
+ }
+ if (
+ !data[host].length &&
+ this.boundUpdate.changed &&
+ this.boundUpdate.changed[storeType] &&
+ this.boundUpdate.changed[storeType][host]
+ ) {
+ delete this.boundUpdate.changed[storeType][host];
+ }
+ }
+ }
+
+ this.batchTimer = setTimeout(() => {
+ clearTimeout(this.batchTimer);
+ this.emit("stores-update", this.boundUpdate);
+ this.boundUpdate = {};
+ }, BATCH_DELAY);
+
+ return null;
+ }
+
+ /**
+ * This method removes data from the this.boundUpdate object in the same
+ * manner like this.update() adds data to it.
+ *
+ * @param {string} action
+ * The type of change. One of "added", "changed" or "deleted"
+ * @param {string} storeType
+ * The storage actor for which you want to remove the updates data.
+ * @param {object} data
+ * The update object. This object is of the following format:
+ * - {
+ * <host1>: [<store_names1>, <store_name2>...],
+ * <host2>: [<store_names34>...],
+ * }
+ * Where host1, host2 are the hosts which you want to remove and
+ * [<store_namesX] is an array of the names of the store objects.
+ */
+ removeNamesFromUpdateList(action, storeType, data) {
+ for (const host in data) {
+ if (
+ this.boundUpdate[action] &&
+ this.boundUpdate[action][storeType] &&
+ this.boundUpdate[action][storeType][host]
+ ) {
+ for (const name of data[host]) {
+ const index = this.boundUpdate[action][storeType][host].indexOf(name);
+ if (index > -1) {
+ this.boundUpdate[action][storeType][host].splice(index, 1);
+ }
+ }
+ if (!this.boundUpdate[action][storeType][host].length) {
+ delete this.boundUpdate[action][storeType][host];
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/devtools/server/actors/resources/websockets.js b/devtools/server/actors/resources/websockets.js
new file mode 100644
index 0000000000..5845357a9c
--- /dev/null
+++ b/devtools/server/actors/resources/websockets.js
@@ -0,0 +1,196 @@
+/* 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 {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+const {
+ TYPES: { WEBSOCKET },
+} = require("resource://devtools/server/actors/resources/index.js");
+
+const webSocketEventService = Cc[
+ "@mozilla.org/websocketevent/service;1"
+].getService(Ci.nsIWebSocketEventService);
+
+class WebSocketWatcher {
+ constructor() {
+ this.windowIds = new Set();
+ // Maintains a map of all the connection channels per websocket
+ // The map item is keyed on the `webSocketSerialID` and stores
+ // the `httpChannelId` as value.
+ this.connections = new Map();
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onWindowDestroy = this.onWindowDestroy.bind(this);
+ }
+
+ static createResource(wsMessageType, eventParams) {
+ return {
+ resourceType: WEBSOCKET,
+ wsMessageType,
+ ...eventParams,
+ };
+ }
+
+ static prepareFramePayload(targetActor, frame) {
+ const payload = new LongStringActor(targetActor.conn, frame.payload);
+ targetActor.manage(payload);
+ return payload.form();
+ }
+
+ watch(targetActor, { onAvailable }) {
+ this.targetActor = targetActor;
+ this.onAvailable = onAvailable;
+
+ for (const window of this.targetActor.windows) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+
+ // On navigate/reload we should re-start listening with the
+ // new `innerWindowID`
+ this.targetActor.on("window-ready", this.onWindowReady);
+ this.targetActor.on("window-destroyed", this.onWindowDestroy);
+ }
+
+ onWindowReady({ window }) {
+ if (!this.targetActor.followWindowGlobalLifeCycle) {
+ const { innerWindowId } = window.windowGlobalChild;
+ this.startListening(innerWindowId);
+ }
+ }
+
+ onWindowDestroy({ id }) {
+ this.stopListening(id);
+ }
+
+ startListening(innerWindowId) {
+ if (!this.windowIds.has(innerWindowId)) {
+ this.windowIds.add(innerWindowId);
+ webSocketEventService.addListener(innerWindowId, this);
+ }
+ }
+
+ stopListening(innerWindowId) {
+ if (this.windowIds.has(innerWindowId)) {
+ this.windowIds.delete(innerWindowId);
+ if (!webSocketEventService.hasListenerFor(innerWindowId)) {
+ // The listener might have already been cleaned up on `window-destroy`.
+ console.warn(
+ "Already stopped listening to websocket events for this window."
+ );
+ return;
+ }
+ webSocketEventService.removeListener(innerWindowId, this);
+ }
+ }
+
+ destroy() {
+ for (const id of this.windowIds) {
+ this.stopListening(id);
+ }
+ this.targetActor.off("window-ready", this.onWindowReady);
+ this.targetActor.off("window-destroyed", this.onWindowDestroy);
+ }
+
+ // methods for the nsIWebSocketEventService
+ webSocketCreated(webSocketSerialID, uri, protocols) {}
+
+ webSocketOpened(
+ webSocketSerialID,
+ effectiveURI,
+ protocols,
+ extensions,
+ httpChannelId
+ ) {
+ this.connections.set(webSocketSerialID, httpChannelId);
+ const resource = WebSocketWatcher.createResource("webSocketOpened", {
+ httpChannelId,
+ effectiveURI,
+ protocols,
+ extensions,
+ });
+
+ this.onAvailable([resource]);
+ }
+
+ webSocketMessageAvailable(webSocketSerialID, data, messageType) {}
+
+ webSocketClosed(webSocketSerialID, wasClean, code, reason) {
+ const httpChannelId = this.connections.get(webSocketSerialID);
+ this.connections.delete(webSocketSerialID);
+
+ const resource = WebSocketWatcher.createResource("webSocketClosed", {
+ httpChannelId,
+ wasClean,
+ code,
+ reason,
+ });
+
+ this.onAvailable([resource]);
+ }
+
+ frameReceived(webSocketSerialID, frame) {
+ const httpChannelId = this.connections.get(webSocketSerialID);
+ if (!httpChannelId) {
+ return;
+ }
+
+ const payload = WebSocketWatcher.prepareFramePayload(
+ this.targetActor,
+ frame
+ );
+ const resource = WebSocketWatcher.createResource("frameReceived", {
+ httpChannelId,
+ data: {
+ type: "received",
+ payload,
+ timeStamp: frame.timeStamp,
+ finBit: frame.finBit,
+ rsvBit1: frame.rsvBit1,
+ rsvBit2: frame.rsvBit2,
+ rsvBit3: frame.rsvBit3,
+ opCode: frame.opCode,
+ mask: frame.mask,
+ maskBit: frame.maskBit,
+ },
+ });
+
+ this.onAvailable([resource]);
+ }
+
+ frameSent(webSocketSerialID, frame) {
+ const httpChannelId = this.connections.get(webSocketSerialID);
+
+ if (!httpChannelId) {
+ return;
+ }
+
+ const payload = WebSocketWatcher.prepareFramePayload(
+ this.targetActor,
+ frame
+ );
+ const resource = WebSocketWatcher.createResource("frameSent", {
+ httpChannelId,
+ data: {
+ type: "sent",
+ payload,
+ timeStamp: frame.timeStamp,
+ finBit: frame.finBit,
+ rsvBit1: frame.rsvBit1,
+ rsvBit2: frame.rsvBit2,
+ rsvBit3: frame.rsvBit3,
+ opCode: frame.opCode,
+ mask: frame.mask,
+ maskBit: frame.maskBit,
+ },
+ });
+
+ this.onAvailable([resource]);
+ }
+}
+
+module.exports = WebSocketWatcher;
diff --git a/devtools/server/actors/root.js b/devtools/server/actors/root.js
new file mode 100644
index 0000000000..df16c70b2f
--- /dev/null
+++ b/devtools/server/actors/root.js
@@ -0,0 +1,606 @@
+/* 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";
+
+// protocol.js uses objects as exceptions in order to define
+// error packets.
+/* eslint-disable no-throw-literal */
+
+const { Actor, Pool } = require("resource://devtools/shared/protocol.js");
+const { rootSpec } = require("resource://devtools/shared/specs/root.js");
+
+const {
+ LazyPool,
+ createExtraActors,
+} = require("resource://devtools/shared/protocol/lazy-pool.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "ProcessDescriptorActor",
+ "resource://devtools/server/actors/descriptors/process.js",
+ true
+);
+
+/* Root actor for the remote debugging protocol. */
+
+/**
+ * Create a remote debugging protocol root actor.
+ *
+ * @param conn
+ * The DevToolsServerConnection whose root actor we are constructing.
+ *
+ * @param parameters
+ * The properties of |parameters| provide backing objects for the root
+ * actor's requests; if a given property is omitted from |parameters|, the
+ * root actor won't implement the corresponding requests or notifications.
+ * Supported properties:
+ *
+ * - tabList: a live list (see below) of target actors for tabs. If present,
+ * the new root actor supports the 'listTabs' request, providing the live
+ * list's elements as its target actors, and sending 'tabListChanged'
+ * notifications when the live list's contents change. One actor in
+ * this list must have a true '.selected' property.
+ *
+ * - addonList: a live list (see below) of addon actors. If present, the
+ * new root actor supports the 'listAddons' request, providing the live
+ * list's elements as its addon actors, and sending 'addonListchanged'
+ * notifications when the live list's contents change.
+ *
+ * - globalActorFactories: an object |A| describing further actors to
+ * attach to the 'listTabs' reply. This is the type accumulated by
+ * ActorRegistry.addGlobalActor. For each own property |P| of |A|,
+ * the root actor adds a property named |P| to the 'listTabs'
+ * reply whose value is the name of an actor constructed by
+ * |A[P]|.
+ *
+ * - onShutdown: a function to call when the root actor is destroyed.
+ *
+ * Instance properties:
+ *
+ * - applicationType: the string the root actor will include as the
+ * "applicationType" property in the greeting packet. By default, this
+ * is "browser".
+ *
+ * Live lists:
+ *
+ * A "live list", as used for the |tabList|, is an object that presents a
+ * list of actors, and also notifies its clients of changes to the list. A
+ * live list's interface is two properties:
+ *
+ * - getList: a method that returns a promise to the contents of the list.
+ *
+ * - onListChanged: a handler called, with no arguments, when the set of
+ * values the iterator would produce has changed since the last
+ * time 'iterator' was called. This may only be set to null or a
+ * callable value (one for which the typeof operator returns
+ * 'function'). (Note that the live list will not call the
+ * onListChanged handler until the list has been iterated over
+ * once; if nobody's seen the list in the first place, nobody
+ * should care if its contents have changed!)
+ *
+ * When the list changes, the list implementation should ensure that any
+ * actors yielded in previous iterations whose referents (tabs) still exist
+ * get yielded again in subsequent iterations. If the underlying referent
+ * is the same, the same actor should be presented for it.
+ *
+ * The root actor registers an 'onListChanged' handler on the appropriate
+ * list when it may need to send the client 'tabListChanged' notifications,
+ * and is careful to remove the handler whenever it does not need to send
+ * such notifications (including when it is destroyed). This means that
+ * live list implementations can use the state of the handler property (set
+ * or null) to install and remove observers and event listeners.
+ *
+ * Note that, as the only way for the root actor to see the members of the
+ * live list is to begin an iteration over the list, the live list need not
+ * actually produce any actors until they are reached in the course of
+ * iteration: alliterative lazy live lists.
+ */
+class RootActor extends Actor {
+ constructor(conn, parameters) {
+ super(conn, rootSpec);
+
+ this._parameters = parameters;
+ this._onTabListChanged = this.onTabListChanged.bind(this);
+ this._onAddonListChanged = this.onAddonListChanged.bind(this);
+ this._onWorkerListChanged = this.onWorkerListChanged.bind(this);
+ this._onServiceWorkerRegistrationListChanged =
+ this.onServiceWorkerRegistrationListChanged.bind(this);
+ this._onProcessListChanged = this.onProcessListChanged.bind(this);
+
+ this._extraActors = {};
+
+ this._globalActorPool = new LazyPool(this.conn);
+
+ this.applicationType = "browser";
+
+ // Compute the list of all supported Root Resources
+ const supportedResources = {};
+ for (const resourceType in Resources.RootResources) {
+ supportedResources[resourceType] = true;
+ }
+
+ this.traits = {
+ networkMonitor: true,
+ resources: supportedResources,
+ // @backward-compat { version 84 } Expose the pref value to the client.
+ // Services.prefs is undefined in xpcshell tests.
+ workerConsoleApiMessagesDispatchedToMainThread: Services.prefs
+ ? Services.prefs.getBoolPref(
+ "dom.worker.console.dispatch_events_to_main_thread"
+ )
+ : true,
+ // @backward-compat { version 123 } A new Objects Manager front has a new "releaseActors" method.
+ supportsReleaseActors: true,
+ };
+ }
+
+ /**
+ * Return a 'hello' packet as specified by the Remote Debugging Protocol.
+ */
+ sayHello() {
+ return {
+ from: this.actorID,
+ applicationType: this.applicationType,
+ /* This is not in the spec, but it's used by tests. */
+ testConnectionPrefix: this.conn.prefix,
+ traits: this.traits,
+ };
+ }
+
+ forwardingCancelled(prefix) {
+ return {
+ from: this.actorID,
+ type: "forwardingCancelled",
+ prefix,
+ };
+ }
+
+ /**
+ * Destroys the actor from the browser window.
+ */
+ destroy() {
+ Resources.unwatchAllResources(this);
+
+ super.destroy();
+
+ /* Tell the live lists we aren't watching any more. */
+ if (this._parameters.tabList) {
+ this._parameters.tabList.destroy();
+ }
+ if (this._parameters.addonList) {
+ this._parameters.addonList.onListChanged = null;
+ }
+ if (this._parameters.workerList) {
+ this._parameters.workerList.destroy();
+ }
+ if (this._parameters.serviceWorkerRegistrationList) {
+ this._parameters.serviceWorkerRegistrationList.onListChanged = null;
+ }
+ if (this._parameters.processList) {
+ this._parameters.processList.onListChanged = null;
+ }
+ if (typeof this._parameters.onShutdown === "function") {
+ this._parameters.onShutdown();
+ }
+ // Cleanup Actors on destroy
+ if (this._tabDescriptorActorPool) {
+ this._tabDescriptorActorPool.destroy();
+ }
+ if (this._processDescriptorActorPool) {
+ this._processDescriptorActorPool.destroy();
+ }
+ if (this._globalActorPool) {
+ this._globalActorPool.destroy();
+ }
+ if (this._addonTargetActorPool) {
+ this._addonTargetActorPool.destroy();
+ }
+ if (this._workerDescriptorActorPool) {
+ this._workerDescriptorActorPool.destroy();
+ }
+ if (this._frameDescriptorActorPool) {
+ this._frameDescriptorActorPool.destroy();
+ }
+
+ if (this._serviceWorkerRegistrationActorPool) {
+ this._serviceWorkerRegistrationActorPool.destroy();
+ }
+ this._extraActors = null;
+ this._tabDescriptorActorPool = null;
+ this._globalActorPool = null;
+ this._parameters = null;
+ }
+
+ /**
+ * Gets the "root" form, which lists all the global actors that affect the entire
+ * browser.
+ */
+ getRoot() {
+ // Create global actors
+ if (!this._globalActorPool) {
+ this._globalActorPool = new LazyPool(this.conn);
+ }
+ const actors = createExtraActors(
+ this._parameters.globalActorFactories,
+ this._globalActorPool,
+ this
+ );
+
+ return actors;
+ }
+
+ /* The 'listTabs' request and the 'tabListChanged' notification. */
+
+ /**
+ * Handles the listTabs request. The actors will survive until at least
+ * the next listTabs request.
+ */
+ async listTabs() {
+ const tabList = this._parameters.tabList;
+ if (!tabList) {
+ throw {
+ error: "noTabs",
+ message: "This root actor has no browser tabs.",
+ };
+ }
+
+ // Now that a client has requested the list of tabs, we reattach the onListChanged
+ // listener in order to be notified if the list of tabs changes again in the future.
+ tabList.onListChanged = this._onTabListChanged;
+
+ // Walk the tab list, accumulating the array of target actors for the reply, and
+ // moving all the actors to a new Pool. We'll replace the old tab target actor
+ // pool with the one we build here, thus retiring any actors that didn't get listed
+ // again, and preparing any new actors to receive packets.
+ const newActorPool = new Pool(this.conn, "listTabs-tab-descriptors");
+
+ const tabDescriptorActors = await tabList.getList();
+ for (const tabDescriptorActor of tabDescriptorActors) {
+ newActorPool.manage(tabDescriptorActor);
+ }
+
+ // Drop the old actorID -> actor map. Actors that still mattered were added to the
+ // new map; others will go away.
+ if (this._tabDescriptorActorPool) {
+ this._tabDescriptorActorPool.destroy();
+ }
+ this._tabDescriptorActorPool = newActorPool;
+
+ return tabDescriptorActors;
+ }
+
+ /**
+ * Return the tab descriptor actor for the tab identified by one of the IDs
+ * passed as argument.
+ *
+ * See BrowserTabList.prototype.getTab for the definition of these IDs.
+ */
+ async getTab({ browserId }) {
+ const tabList = this._parameters.tabList;
+ if (!tabList) {
+ throw {
+ error: "noTabs",
+ message: "This root actor has no browser tabs.",
+ };
+ }
+ if (!this._tabDescriptorActorPool) {
+ this._tabDescriptorActorPool = new Pool(
+ this.conn,
+ "getTab-tab-descriptors"
+ );
+ }
+
+ let descriptorActor;
+ try {
+ descriptorActor = await tabList.getTab({
+ browserId,
+ });
+ } catch (error) {
+ if (error.error) {
+ // Pipe expected errors as-is to the client
+ throw error;
+ }
+ throw {
+ error: "noTab",
+ message: "Unexpected error while calling getTab(): " + error,
+ };
+ }
+
+ descriptorActor.parentID = this.actorID;
+ this._tabDescriptorActorPool.manage(descriptorActor);
+
+ return descriptorActor;
+ }
+
+ onTabListChanged() {
+ this.conn.send({ from: this.actorID, type: "tabListChanged" });
+ /* It's a one-shot notification; no need to watch any more. */
+ this._parameters.tabList.onListChanged = null;
+ }
+
+ /**
+ * This function can receive the following option from devtools client.
+ *
+ * @param {Object} option
+ * - iconDataURL: {boolean}
+ * When true, make data url from the icon of addon, then make possible to
+ * access by iconDataURL in the actor. The iconDataURL is useful when
+ * retrieving addons from a remote device, because the raw iconURL might not
+ * be accessible on the client.
+ */
+ async listAddons(option) {
+ const addonList = this._parameters.addonList;
+ if (!addonList) {
+ throw {
+ error: "noAddons",
+ message: "This root actor has no browser addons.",
+ };
+ }
+
+ // Reattach the onListChanged listener now that a client requested the list.
+ addonList.onListChanged = this._onAddonListChanged;
+
+ const addonTargetActors = await addonList.getList();
+ const addonTargetActorPool = new Pool(this.conn, "addon-descriptors");
+ for (const addonTargetActor of addonTargetActors) {
+ if (option.iconDataURL) {
+ await addonTargetActor.loadIconDataURL();
+ }
+
+ addonTargetActorPool.manage(addonTargetActor);
+ }
+
+ if (this._addonTargetActorPool) {
+ this._addonTargetActorPool.destroy();
+ }
+ this._addonTargetActorPool = addonTargetActorPool;
+
+ return addonTargetActors;
+ }
+
+ onAddonListChanged() {
+ this.conn.send({ from: this.actorID, type: "addonListChanged" });
+ this._parameters.addonList.onListChanged = null;
+ }
+
+ listWorkers() {
+ const workerList = this._parameters.workerList;
+ if (!workerList) {
+ throw {
+ error: "noWorkers",
+ message: "This root actor has no workers.",
+ };
+ }
+
+ // Reattach the onListChanged listener now that a client requested the list.
+ workerList.onListChanged = this._onWorkerListChanged;
+
+ return workerList.getList().then(actors => {
+ const pool = new Pool(this.conn, "worker-targets");
+ for (const actor of actors) {
+ pool.manage(actor);
+ }
+
+ // Do not destroy the pool before transfering ownership to the newly created
+ // pool, so that we do not accidently destroy actors that are still in use.
+ if (this._workerDescriptorActorPool) {
+ this._workerDescriptorActorPool.destroy();
+ }
+
+ this._workerDescriptorActorPool = pool;
+
+ return {
+ workers: actors,
+ };
+ });
+ }
+
+ onWorkerListChanged() {
+ this.conn.send({ from: this.actorID, type: "workerListChanged" });
+ this._parameters.workerList.onListChanged = null;
+ }
+
+ listServiceWorkerRegistrations() {
+ const registrationList = this._parameters.serviceWorkerRegistrationList;
+ if (!registrationList) {
+ throw {
+ error: "noServiceWorkerRegistrations",
+ message: "This root actor has no service worker registrations.",
+ };
+ }
+
+ // Reattach the onListChanged listener now that a client requested the list.
+ registrationList.onListChanged =
+ this._onServiceWorkerRegistrationListChanged;
+
+ return registrationList.getList().then(actors => {
+ const pool = new Pool(this.conn, "service-workers-registrations");
+ for (const actor of actors) {
+ pool.manage(actor);
+ }
+
+ if (this._serviceWorkerRegistrationActorPool) {
+ this._serviceWorkerRegistrationActorPool.destroy();
+ }
+ this._serviceWorkerRegistrationActorPool = pool;
+
+ return {
+ registrations: actors,
+ };
+ });
+ }
+
+ onServiceWorkerRegistrationListChanged() {
+ this.conn.send({
+ from: this.actorID,
+ type: "serviceWorkerRegistrationListChanged",
+ });
+ this._parameters.serviceWorkerRegistrationList.onListChanged = null;
+ }
+
+ listProcesses() {
+ const { processList } = this._parameters;
+ if (!processList) {
+ throw {
+ error: "noProcesses",
+ message: "This root actor has no processes.",
+ };
+ }
+ processList.onListChanged = this._onProcessListChanged;
+ const processes = processList.getList();
+ const pool = new Pool(this.conn, "process-descriptors");
+ for (const metadata of processes) {
+ let processDescriptor = this._getKnownDescriptor(
+ metadata.id,
+ this._processDescriptorActorPool
+ );
+ if (!processDescriptor) {
+ processDescriptor = new ProcessDescriptorActor(this.conn, metadata);
+ }
+ pool.manage(processDescriptor);
+ }
+ // Do not destroy the pool before transfering ownership to the newly created
+ // pool, so that we do not accidently destroy actors that are still in use.
+ if (this._processDescriptorActorPool) {
+ this._processDescriptorActorPool.destroy();
+ }
+ this._processDescriptorActorPool = pool;
+ return [...this._processDescriptorActorPool.poolChildren()];
+ }
+
+ onProcessListChanged() {
+ this.conn.send({ from: this.actorID, type: "processListChanged" });
+ this._parameters.processList.onListChanged = null;
+ }
+
+ async getProcess(id) {
+ if (!DevToolsServer.allowChromeProcess) {
+ throw {
+ error: "forbidden",
+ message: "You are not allowed to debug chrome.",
+ };
+ }
+ if (typeof id != "number") {
+ throw {
+ error: "wrongParameter",
+ message: "getProcess requires a valid `id` attribute.",
+ };
+ }
+ this._processDescriptorActorPool =
+ this._processDescriptorActorPool ||
+ new Pool(this.conn, "process-descriptors");
+
+ let processDescriptor = this._getKnownDescriptor(
+ id,
+ this._processDescriptorActorPool
+ );
+ if (!processDescriptor) {
+ // The parent process has id == 0, based on ProcessActorList::getList implementation
+ const options = { id, parent: id === 0 };
+ processDescriptor = new ProcessDescriptorActor(this.conn, options);
+ this._processDescriptorActorPool.manage(processDescriptor);
+ }
+ return processDescriptor;
+ }
+
+ _getKnownDescriptor(id, pool) {
+ // if there is no pool, then we do not have any descriptors
+ if (!pool) {
+ return null;
+ }
+ for (const descriptor of pool.poolChildren()) {
+ if (descriptor.id === id) {
+ return descriptor;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Remove the extra actor (added by ActorRegistry.addGlobalActor or
+ * ActorRegistry.addTargetScopedActor) name |name|.
+ */
+ removeActorByName(name) {
+ if (name in this._extraActors) {
+ const actor = this._extraActors[name];
+ if (this._globalActorPool.has(actor.actorID)) {
+ actor.destroy();
+ }
+ if (this._tabDescriptorActorPool) {
+ // Iterate over WindowGlobalTargetActor instances to also remove target-scoped
+ // actors created during listTabs for each document.
+ for (const tab in this._tabDescriptorActorPool.poolChildren()) {
+ tab.removeActorByName(name);
+ }
+ }
+ delete this._extraActors[name];
+ }
+ }
+
+ /**
+ * Start watching for a list of resource types.
+ *
+ * See WatcherActor.watchResources.
+ */
+ async watchResources(resourceTypes) {
+ await Resources.watchResources(this, resourceTypes);
+ }
+
+ /**
+ * Stop watching for a list of resource types.
+ *
+ * See WatcherActor.unwatchResources.
+ */
+ unwatchResources(resourceTypes) {
+ Resources.unwatchResources(this, resourceTypes);
+ }
+
+ /**
+ * Clear resources of a list of resource types.
+ *
+ * See WatcherActor.clearResources.
+ */
+ clearResources(resourceTypes) {
+ Resources.clearResources(this, resourceTypes);
+ }
+
+ /**
+ * Called by Resource Watchers, when new resources are available, updated or destroyed.
+ *
+ * @param String updateType
+ * Can be "available", "updated" or "destroyed"
+ * @param Array<json> resources
+ * List of all resources. A resource is a JSON object piped over to the client.
+ * It can contain actor IDs.
+ * It can also be or contain an actor form, to be manually marshalled by the client.
+ * (i.e. the frontend would have to manually instantiate a Front for the given actor form)
+ */
+ notifyResources(updateType, resources) {
+ if (resources.length === 0) {
+ // Don't try to emit if the resources array is empty.
+ return;
+ }
+
+ switch (updateType) {
+ case "available":
+ this.emit(`resource-available-form`, resources);
+ break;
+ case "updated":
+ this.emit(`resource-updated-form`, resources);
+ break;
+ case "destroyed":
+ this.emit(`resource-destroyed-form`, resources);
+ break;
+ default:
+ throw new Error("Unsupported update type: " + updateType);
+ }
+ }
+}
+
+exports.RootActor = RootActor;
diff --git a/devtools/server/actors/screenshot-content.js b/devtools/server/actors/screenshot-content.js
new file mode 100644
index 0000000000..0e47ae1157
--- /dev/null
+++ b/devtools/server/actors/screenshot-content.js
@@ -0,0 +1,144 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ screenshotContentSpec,
+} = require("resource://devtools/shared/specs/screenshot-content.js");
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const STRINGS_URI = "devtools/shared/locales/screenshot.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
+loader.lazyRequireGetter(
+ this,
+ ["getCurrentZoom", "getRect"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+exports.ScreenshotContentActor = class ScreenshotContentActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, screenshotContentSpec);
+ this.targetActor = targetActor;
+ }
+
+ _getRectForNode(node) {
+ const originWindow = this.targetActor.ignoreSubFrames
+ ? node.ownerGlobal
+ : node.ownerGlobal.top;
+ return getRect(originWindow, node, node.ownerGlobal);
+ }
+
+ /**
+ * Retrieve some window-related information that will be passed to the parent process
+ * to actually generate the screenshot.
+ *
+ * @param {Object} args
+ * @param {Boolean} args.fullpage: Should the screenshot be the height of the whole page
+ * @param {String} args.selector: A CSS selector for the element we should take the
+ * screenshot of. The function will return true for the `error` property
+ * if the screenshot does not match any element.
+ * @param {String} args.nodeActorID: The actorID of the node actor matching the element
+ * we should take the screenshot of.
+ * @returns {Object} An object with the following properties:
+ * - error {Boolean}: Set to true if an issue was encountered that prevents
+ * taking the screenshot
+ * - messages {Array<Object{text, level}>}: An array of objects representing
+ * the messages emitted throught the process and their level.
+ * - windowDpr {Number}: Value of window.devicePixelRatio
+ * - windowZoom {Number}: The page current zoom level
+ * - rect {Object}: Object with left, top, width and height properties
+ * representing the rect **inside the browser element** that should be rendered.
+ * For screenshot of the current viewport, we return null, as expected by the
+ * `drawSnapshot` API.
+ */
+ prepareCapture({ fullpage, selector, nodeActorID }) {
+ const { window } = this.targetActor;
+ // Use the override if set, note that the override is not returned by
+ // devicePixelRatio on privileged code, see bug 1759962.
+ //
+ // FIXME(bug 1760711): Whether zoom is included in devicePixelRatio depends
+ // on whether there's an override, this is a bit suspect.
+ const windowDpr =
+ window.browsingContext.top.overrideDPPX || window.devicePixelRatio;
+ const windowZoom = getCurrentZoom(window);
+ const messages = [];
+
+ // If we're going to take the current view of the page, we don't need to compute a rect,
+ // since it's the default behaviour of drawSnapshot.
+ if (!fullpage && !selector && !nodeActorID) {
+ return {
+ rect: null,
+ messages,
+ windowDpr,
+ windowZoom,
+ };
+ }
+
+ let left;
+ let top;
+ let width;
+ let height;
+
+ if (fullpage) {
+ // We don't want to render the scrollbars
+ const winUtils = window.windowUtils;
+ const scrollbarHeight = {};
+ const scrollbarWidth = {};
+ winUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
+
+ left = 0;
+ top = 0;
+ width =
+ window.innerWidth +
+ window.scrollMaxX -
+ window.scrollMinX -
+ scrollbarWidth.value;
+ height =
+ window.innerHeight +
+ window.scrollMaxY -
+ window.scrollMinY -
+ scrollbarHeight.value;
+ } else if (selector) {
+ const node = window.document.querySelector(selector);
+
+ if (!node) {
+ messages.push({
+ level: "warn",
+ text: L10N.getFormatStr("screenshotNoSelectorMatchWarning", selector),
+ });
+
+ return {
+ error: true,
+ messages,
+ };
+ }
+
+ ({ left, top, width, height } = this._getRectForNode(node));
+ } else if (nodeActorID) {
+ const nodeActor = this.conn.getActor(nodeActorID);
+ if (!nodeActor) {
+ messages.push({
+ level: "error",
+ text: `Screenshot actor failed to find Node actor for '${nodeActorID}'`,
+ });
+
+ return {
+ error: true,
+ messages,
+ };
+ }
+
+ ({ left, top, width, height } = this._getRectForNode(nodeActor.rawNode));
+ }
+
+ return {
+ windowDpr,
+ windowZoom,
+ rect: { left, top, width, height },
+ messages,
+ };
+ }
+};
diff --git a/devtools/server/actors/screenshot.js b/devtools/server/actors/screenshot.js
new file mode 100644
index 0000000000..d1c5cd5b17
--- /dev/null
+++ b/devtools/server/actors/screenshot.js
@@ -0,0 +1,25 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ screenshotSpec,
+} = require("resource://devtools/shared/specs/screenshot.js");
+
+const {
+ captureScreenshot,
+} = require("resource://devtools/server/actors/utils/capture-screenshot.js");
+
+exports.ScreenshotActor = class ScreenshotActor extends Actor {
+ constructor(conn) {
+ super(conn, screenshotSpec);
+ }
+
+ async capture(args) {
+ const browsingContext = BrowsingContext.get(args.browsingContextID);
+ return captureScreenshot(args, browsingContext);
+ }
+};
diff --git a/devtools/server/actors/source.js b/devtools/server/actors/source.js
new file mode 100644
index 0000000000..ff08bcb4c2
--- /dev/null
+++ b/devtools/server/actors/source.js
@@ -0,0 +1,694 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { sourceSpec } = require("resource://devtools/shared/specs/source.js");
+
+const {
+ setBreakpointAtEntryPoints,
+} = require("resource://devtools/server/actors/breakpoint.js");
+const {
+ getSourcemapBaseURL,
+} = require("resource://devtools/server/actors/utils/source-map-utils.js");
+const {
+ getDebuggerSourceURL,
+} = require("resource://devtools/server/actors/utils/source-url.js");
+
+loader.lazyRequireGetter(
+ this,
+ "ArrayBufferActor",
+ "resource://devtools/server/actors/array-buffer.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "resource://devtools/server/actors/string.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "DevToolsUtils",
+ "resource://devtools/shared/DevToolsUtils.js"
+);
+
+const windowsDrive = /^([a-zA-Z]:)/;
+
+function resolveSourceURL(sourceURL, targetActor) {
+ if (sourceURL) {
+ try {
+ let baseURL;
+ if (targetActor.window) {
+ baseURL = targetActor.window.location?.href;
+ }
+ // For worker, we don't have easy access to location,
+ // so pull extra information directly from the target actor.
+ if (targetActor.workerUrl) {
+ baseURL = targetActor.workerUrl;
+ }
+ return new URL(sourceURL, baseURL || undefined).href;
+ } catch (err) {}
+ }
+
+ return null;
+}
+function getSourceURL(source, targetActor) {
+ // Some eval sources have URLs, but we want to explicitly ignore those because
+ // they are generally useless strings like "eval" or "debugger eval code".
+ let resourceURL = getDebuggerSourceURL(source) || "";
+
+ // Strip out eventual stack trace stored in Source's url.
+ // (not clear if that still happens)
+ resourceURL = resourceURL.split(" -> ").pop();
+
+ // Debugger.Source.url attribute may be of the form:
+ // "http://example.com/foo line 10 > inlineScript"
+ // because of the following function `js::FormatIntroducedFilename`:
+ // https://searchfox.org/mozilla-central/rev/253ae246f642fe9619597f44de3b087f94e45a2d/js/src/vm/JSScript.cpp#1816-1846
+ // This isn't so easy to reproduce, but browser_dbg-breakpoints-popup.js's testPausedInTwoPopups covers this
+ resourceURL = resourceURL.replace(/ line \d+ > .*$/, "");
+
+ // A "//# sourceURL=" pragma should basically be treated as a source file's
+ // full URL, so that is what we want to use as the base if it is present.
+ // If this is not an absolute URL, this will mean the maps in the file
+ // will not have a valid base URL, but that is up to tooling that
+ let result = resolveSourceURL(source.displayURL, targetActor);
+ if (!result) {
+ result = resolveSourceURL(resourceURL, targetActor) || resourceURL;
+
+ // In XPCShell tests, the source URL isn't actually a URL, it's a file path.
+ // That causes issues because "C:/folder/file.js" is parsed as a URL with
+ // "c:" as the URL scheme, which causes the drive letter to be unexpectedly
+ // lower-cased when the parsed URL is re-serialized. To avoid that, we
+ // detect that case and re-uppercase it again. This is a bit gross and
+ // ideally it seems like XPCShell tests should use file:// URLs for files,
+ // but alas they do not.
+ if (
+ resourceURL &&
+ resourceURL.match(windowsDrive) &&
+ result.slice(0, 2) == resourceURL.slice(0, 2).toLowerCase()
+ ) {
+ result = resourceURL.slice(0, 2) + result.slice(2);
+ }
+ }
+
+ // Avoid returning empty string and return null if no URL is found
+ return result || null;
+}
+
+/**
+ * A SourceActor provides information about the source of a script. Source
+ * actors are 1:1 with Debugger.Source objects.
+ *
+ * @param Debugger.Source source
+ * The source object we are representing.
+ * @param ThreadActor thread
+ * The current thread actor.
+ */
+class SourceActor extends Actor {
+ constructor({ source, thread }) {
+ super(thread.conn, sourceSpec);
+
+ this._threadActor = thread;
+ this._url = undefined;
+ this._source = source;
+ this.__isInlineSource = undefined;
+ }
+
+ get _isInlineSource() {
+ const source = this._source;
+ if (this.__isInlineSource === undefined) {
+ // If the source has a usable displayURL, the source is treated as not
+ // inlined because it has its own URL.
+ // Also consider sources loaded from <iframe srcdoc> as independant sources,
+ // because we can't easily fetch the full html content of the srcdoc attribute.
+ this.__isInlineSource =
+ source.introductionType === "inlineScript" &&
+ !resolveSourceURL(source.displayURL, this.threadActor._parent) &&
+ !this.url.startsWith("about:srcdoc");
+ }
+ return this.__isInlineSource;
+ }
+
+ get threadActor() {
+ return this._threadActor;
+ }
+ get sourcesManager() {
+ return this._threadActor.sourcesManager;
+ }
+ get dbg() {
+ return this.threadActor.dbg;
+ }
+ get breakpointActorMap() {
+ return this.threadActor.breakpointActorMap;
+ }
+ get url() {
+ if (this._url === undefined) {
+ this._url = getSourceURL(this._source, this.threadActor._parent);
+ }
+ return this._url;
+ }
+
+ get extensionName() {
+ if (this._extensionName === undefined) {
+ this._extensionName = null;
+
+ // Cu is not available for workers and so we are not able to get a
+ // WebExtensionPolicy object
+ if (!isWorker && this.url?.startsWith("moz-extension:")) {
+ try {
+ const extURI = Services.io.newURI(this.url);
+ const policy = WebExtensionPolicy.getByURI(extURI);
+ if (policy) {
+ this._extensionName = policy.name;
+ }
+ } catch (e) {
+ console.warn(`Failed to find extension name for ${this.url} : ${e}`);
+ }
+ }
+ }
+
+ return this._extensionName;
+ }
+
+ get internalSourceId() {
+ return this._source.id;
+ }
+
+ form() {
+ const source = this._source;
+
+ let introductionType = source.introductionType;
+ if (
+ introductionType === "srcScript" ||
+ introductionType === "inlineScript" ||
+ introductionType === "injectedScript"
+ ) {
+ // These three used to be one single type, so here we combine them all
+ // so that clients don't see any change in behavior.
+ introductionType = "scriptElement";
+ }
+
+ // NOTE: Debugger.Source.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = source.introductionType === "wasm" ? 0 : 1;
+
+ return {
+ actor: this.actorID,
+ extensionName: this.extensionName,
+ url: this.url,
+ isBlackBoxed: this.sourcesManager.isBlackBoxed(this.url),
+ sourceMapBaseURL: getSourcemapBaseURL(
+ this.url,
+ this.threadActor._parent.window
+ ),
+ sourceMapURL: source.sourceMapURL,
+ introductionType,
+ isInlineSource: this._isInlineSource,
+ sourceStartLine: source.startLine,
+ sourceStartColumn: source.startColumn - columnBase,
+ sourceLength: source.text?.length,
+ };
+ }
+
+ destroy() {
+ const parent = this.getParent();
+ if (parent && parent.sourceActors) {
+ delete parent.sourceActors[this.actorID];
+ }
+ super.destroy();
+ }
+
+ get _isWasm() {
+ return this._source.introductionType === "wasm";
+ }
+
+ async _getSourceText() {
+ if (this._isWasm) {
+ const wasm = this._source.binary;
+ const buffer = wasm.buffer;
+ DevToolsUtils.assert(
+ wasm.byteOffset === 0 && wasm.byteLength === buffer.byteLength,
+ "Typed array from wasm source binary must cover entire buffer"
+ );
+ return {
+ content: buffer,
+ contentType: "text/wasm",
+ };
+ }
+
+ // Use `source.text` if it exists, is not the "no source" string, and
+ // the source isn't one that is inlined into some larger file.
+ // It will be "no source" if the Debugger API wasn't able to load
+ // the source because sources were discarded
+ // (javascript.options.discardSystemSource == true).
+ //
+ // For inline source, we do something special and ignore individual source content.
+ // Instead, each inline source will return the full HTML page content where
+ // the inline source is (i.e. `<script> js source </script>`).
+ //
+ // When using srcdoc attribute on iframes:
+ // <iframe srcdoc="<script> js source </script>"></iframe>
+ // The whole iframe source is going to be considered as an inline source because displayURL is null
+ // and introductionType is inlineScript. But Debugger.Source.text is the only way
+ // to retrieve the source content.
+ if (this._source.text !== "[no source]" && !this._isInlineSource) {
+ return {
+ content: this.actualText(),
+ contentType: "text/javascript",
+ };
+ }
+
+ return this.sourcesManager.urlContents(
+ this.url,
+ /* partial */ false,
+ /* canUseCache */ this._isInlineSource
+ );
+ }
+
+ // Get the actual text of this source, padded so that line numbers will match
+ // up with the source itself.
+ actualText() {
+ // If the source doesn't start at line 1, line numbers in the client will
+ // not match up with those in the source. Pad the text with blank lines to
+ // fix this. This can show up for sources associated with inline scripts
+ // in HTML created via document.write() calls: the script's source line
+ // number is relative to the start of the written HTML, but we show the
+ // source's content by itself.
+ const padding = this._source.startLine
+ ? "\n".repeat(this._source.startLine - 1)
+ : "";
+ return padding + this._source.text;
+ }
+
+ // Return whether the specified fetched contents includes the actual text of
+ // this source in the expected position.
+ contentMatches(fileContents) {
+ const lineBreak = /\r\n?|\n|\u2028|\u2029/;
+ const contentLines = fileContents.content.split(lineBreak);
+ const sourceLines = this._source.text.split(lineBreak);
+ let line = this._source.startLine - 1;
+ for (const sourceLine of sourceLines) {
+ const contentLine = contentLines[line++] || "";
+ if (!contentLine.includes(sourceLine)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ getBreakableLines() {
+ const positions = this._getBreakpointPositions();
+ const lines = new Set();
+ for (const position of positions) {
+ if (!lines.has(position.line)) {
+ lines.add(position.line);
+ }
+ }
+
+ return Array.from(lines);
+ }
+
+ // Get all toplevel scripts in the source. Transitive child scripts must be
+ // found by traversing the child script tree.
+ _getTopLevelDebuggeeScripts() {
+ if (this._scripts) {
+ return this._scripts;
+ }
+
+ let scripts = this.dbg.findScripts({ source: this._source });
+
+ if (!this._isWasm) {
+ // There is no easier way to get the top-level scripts right now, so
+ // we have to build that up the list manually.
+ // Note: It is not valid to simply look for scripts where
+ // `.isFunction == false` because a source may have executed multiple
+ // where some have been GCed and some have not (bug 1627712).
+ const allScripts = new Set(scripts);
+ for (const script of allScripts) {
+ for (const child of script.getChildScripts()) {
+ allScripts.delete(child);
+ }
+ }
+ scripts = [...allScripts];
+ }
+
+ this._scripts = scripts;
+ return scripts;
+ }
+
+ resetDebuggeeScripts() {
+ this._scripts = null;
+ }
+
+ // Get toplevel scripts which contain all breakpoint positions for the source.
+ // This is different from _scripts if we detected that some scripts have been
+ // GC'ed and reparsed the source contents.
+ _getTopLevelBreakpointPositionScripts() {
+ if (this._breakpointPositionScripts) {
+ return this._breakpointPositionScripts;
+ }
+
+ let scripts = this._getTopLevelDebuggeeScripts();
+
+ // We need to find all breakpoint positions, even if scripts associated with
+ // this source have been GC'ed. We detect this by looking for a script which
+ // does not have a function: a source will typically have a top level
+ // non-function script. If this top level script still exists, then it keeps
+ // all its child scripts alive and we will find all breakpoint positions by
+ // scanning the existing scripts. If the top level script has been GC'ed
+ // then we won't find its breakpoint positions, and inner functions may have
+ // been GC'ed as well. In this case we reparse the source and generate a new
+ // and complete set of scripts to look for the breakpoint positions.
+ // Note that in some cases like "new Function(stuff)" there might not be a
+ // top level non-function script, but if there is a non-function script then
+ // it must be at the top level and will keep all other scripts in the source
+ // alive.
+ if (!this._isWasm && !scripts.some(script => !script.isFunction)) {
+ let newScript;
+ try {
+ newScript = this._source.reparse();
+ } catch (e) {
+ // reparse() will throw if the source is not valid JS. This can happen
+ // if this source is the resurrection of a GC'ed source and there are
+ // parse errors in the refetched contents.
+ }
+ if (newScript) {
+ scripts = [newScript];
+ }
+ }
+
+ this._breakpointPositionScripts = scripts;
+ return scripts;
+ }
+
+ // Get all scripts in this source that might include content in the range
+ // specified by the given query.
+ _findDebuggeeScripts(query, forBreakpointPositions) {
+ const scripts = forBreakpointPositions
+ ? this._getTopLevelBreakpointPositionScripts()
+ : this._getTopLevelDebuggeeScripts();
+
+ const {
+ start: { line: startLine = 0, column: startColumn = 0 } = {},
+ end: { line: endLine = Infinity, column: endColumn = Infinity } = {},
+ } = query || {};
+
+ const rv = [];
+ addMatchingScripts(scripts);
+ return rv;
+
+ function scriptMatches(script) {
+ // These tests are approximate, as we can't easily get the script's end
+ // column.
+ let lineCount;
+ try {
+ lineCount = script.lineCount;
+ } catch (err) {
+ // Accessing scripts which were optimized out during parsing can throw
+ // an exception. Tolerate these so that we can still get positions for
+ // other scripts in the source.
+ return false;
+ }
+
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+ if (
+ script.startLine > endLine ||
+ script.startLine + lineCount <= startLine ||
+ (script.startLine == endLine &&
+ script.startColumn - columnBase > endColumn)
+ ) {
+ return false;
+ }
+
+ if (
+ lineCount == 1 &&
+ script.startLine == startLine &&
+ script.startColumn - columnBase + script.sourceLength <= startColumn
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ function addMatchingScripts(childScripts) {
+ for (const script of childScripts) {
+ if (scriptMatches(script)) {
+ rv.push(script);
+ if (script.format === "js") {
+ addMatchingScripts(script.getChildScripts());
+ }
+ }
+ }
+ }
+ }
+
+ _getBreakpointPositions(query) {
+ const scripts = this._findDebuggeeScripts(
+ query,
+ /* forBreakpointPositions */ true
+ );
+
+ const positions = [];
+ for (const script of scripts) {
+ this._addScriptBreakpointPositions(query, script, positions);
+ }
+
+ return (
+ positions
+ // Sort the items by location.
+ .sort((a, b) => {
+ const lineDiff = a.line - b.line;
+ return lineDiff === 0 ? a.column - b.column : lineDiff;
+ })
+ );
+ }
+
+ _addScriptBreakpointPositions(query, script, positions) {
+ const {
+ start: { line: startLine = 0, column: startColumn = 0 } = {},
+ end: { line: endLine = Infinity, column: endColumn = Infinity } = {},
+ } = query || {};
+
+ // NOTE: Debugger.Script.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ const offsets = script.getPossibleBreakpoints();
+ for (const { lineNumber, columnNumber } of offsets) {
+ if (
+ lineNumber < startLine ||
+ (lineNumber === startLine && columnNumber - columnBase < startColumn) ||
+ lineNumber > endLine ||
+ (lineNumber === endLine && columnNumber - columnBase >= endColumn)
+ ) {
+ continue;
+ }
+
+ positions.push({
+ line: lineNumber,
+ column: columnNumber - columnBase,
+ });
+ }
+ }
+
+ getBreakpointPositionsCompressed(query) {
+ const items = this._getBreakpointPositions(query);
+ const compressed = {};
+ for (const { line, column } of items) {
+ if (!compressed[line]) {
+ compressed[line] = [];
+ }
+ compressed[line].push(column);
+ }
+ return compressed;
+ }
+
+ /**
+ * Handler for the "onSource" packet.
+ * @return Object
+ * The return of this function contains a field `contentType`, and
+ * a field `source`. `source` can either be an ArrayBuffer or
+ * a LongString.
+ */
+ async source() {
+ try {
+ const { content, contentType } = await this._getSourceText();
+ if (
+ typeof content === "object" &&
+ content &&
+ content.constructor &&
+ content.constructor.name === "ArrayBuffer"
+ ) {
+ return {
+ source: new ArrayBufferActor(this.threadActor.conn, content),
+ contentType,
+ };
+ }
+
+ return {
+ source: new LongStringActor(this.threadActor.conn, content),
+ contentType,
+ };
+ } catch (error) {
+ throw new Error(
+ "Could not load the source for " +
+ this.url +
+ ".\n" +
+ DevToolsUtils.safeErrorString(error)
+ );
+ }
+ }
+
+ /**
+ * Handler for the "blackbox" packet.
+ */
+ blackbox(range) {
+ this.sourcesManager.blackBox(this.url, range);
+ if (
+ this.threadActor.state == "paused" &&
+ this.threadActor.youngestFrame &&
+ this.threadActor.youngestFrame.script.url == this.url
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Handler for the "unblackbox" packet.
+ */
+ unblackbox(range) {
+ this.sourcesManager.unblackBox(this.url, range);
+ }
+
+ /**
+ * Handler for the "setPausePoints" packet.
+ *
+ * @param Array pausePoints
+ * A dictionary of pausePoint objects
+ *
+ * type PausePoints = {
+ * line: {
+ * column: { break?: boolean, step?: boolean }
+ * }
+ * }
+ */
+ setPausePoints(pausePoints) {
+ const uncompressed = {};
+ const points = {
+ 0: {},
+ 1: { break: true },
+ 2: { step: true },
+ 3: { break: true, step: true },
+ };
+
+ for (const line in pausePoints) {
+ uncompressed[line] = {};
+ for (const col in pausePoints[line]) {
+ uncompressed[line][col] = points[pausePoints[line][col]];
+ }
+ }
+
+ this.pausePoints = uncompressed;
+ }
+
+ /*
+ * Ensure the given BreakpointActor is set as a breakpoint handler on all
+ * scripts that match its location in the generated source.
+ *
+ * @param BreakpointActor actor
+ * The BreakpointActor to be set as a breakpoint handler.
+ *
+ * @returns A Promise that resolves to the given BreakpointActor.
+ */
+ async applyBreakpoint(actor) {
+ const { line, column } = actor.location;
+
+ // Find all entry points that correspond to the given location.
+ const entryPoints = [];
+ if (column === undefined) {
+ // Find all scripts that match the given source actor and line
+ // number.
+ const query = { start: { line }, end: { line } };
+ const scripts = this._findDebuggeeScripts(query).filter(
+ script => !actor.hasScript(script)
+ );
+
+ // NOTE: Debugger.Script.prototype.getPossibleBreakpoints returns
+ // columnNumber in 1-based.
+ // The following code uses columnNumber only for comparing against
+ // other columnNumber, and we don't need to convert to 0-based.
+
+ // This is a line breakpoint, so we add a breakpoint on the first
+ // breakpoint on the line.
+ const lineMatches = [];
+ for (const script of scripts) {
+ const possibleBreakpoints = script.getPossibleBreakpoints({ line });
+ for (const possibleBreakpoint of possibleBreakpoints) {
+ lineMatches.push({ ...possibleBreakpoint, script });
+ }
+ }
+ lineMatches.sort((a, b) => a.columnNumber - b.columnNumber);
+
+ if (lineMatches.length) {
+ // A single Debugger.Source may have _multiple_ Debugger.Scripts
+ // at the same position from multiple evaluations of the source,
+ // so we explicitly want to take all of the matches for the matched
+ // column number.
+ const firstColumn = lineMatches[0].columnNumber;
+ const firstColumnMatches = lineMatches.filter(
+ m => m.columnNumber === firstColumn
+ );
+
+ for (const { script, offset } of firstColumnMatches) {
+ entryPoints.push({ script, offsets: [offset] });
+ }
+ }
+ } else {
+ // Find all scripts that match the given source actor, line,
+ // and column number.
+ const query = { start: { line, column }, end: { line, column } };
+ const scripts = this._findDebuggeeScripts(query).filter(
+ script => !actor.hasScript(script)
+ );
+
+ for (const script of scripts) {
+ // NOTE: getPossibleBreakpoints's minColumn/maxColumn parameters are
+ // 1-based.
+ // Convert to 1-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ // Check to see if the script contains a breakpoint position at
+ // this line and column.
+ const possibleBreakpoint = script
+ .getPossibleBreakpoints({
+ line,
+ minColumn: column + columnBase,
+ maxColumn: column + columnBase + 1,
+ })
+ .pop();
+
+ if (possibleBreakpoint) {
+ const { offset } = possibleBreakpoint;
+ entryPoints.push({ script, offsets: [offset] });
+ }
+ }
+ }
+
+ setBreakpointAtEntryPoints(actor, entryPoints);
+ }
+}
+
+exports.SourceActor = SourceActor;
diff --git a/devtools/server/actors/string.js b/devtools/server/actors/string.js
new file mode 100644
index 0000000000..01c9353ecf
--- /dev/null
+++ b/devtools/server/actors/string.js
@@ -0,0 +1,45 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ longStringSpec,
+} = require("resource://devtools/shared/specs/string.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+exports.LongStringActor = class LongStringActor extends Actor {
+ constructor(conn, str) {
+ super(conn, longStringSpec);
+ this.str = str;
+ this.short = this.str.length < DevToolsServer.LONG_STRING_LENGTH;
+ }
+
+ destroy() {
+ this.str = null;
+ super.destroy();
+ }
+
+ form() {
+ if (this.short) {
+ return this.str;
+ }
+ return {
+ type: "longString",
+ actor: this.actorID,
+ length: this.str.length,
+ initial: this.str.substring(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH),
+ };
+ }
+
+ substring(start, end) {
+ return this.str.substring(start, end);
+ }
+
+ release() {}
+};
diff --git a/devtools/server/actors/style-rule.js b/devtools/server/actors/style-rule.js
new file mode 100644
index 0000000000..e9f39fa3d0
--- /dev/null
+++ b/devtools/server/actors/style-rule.js
@@ -0,0 +1,1328 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ styleRuleSpec,
+} = require("resource://devtools/shared/specs/style-rule.js");
+
+const { getCSSLexer } = require("resource://devtools/shared/css/lexer.js");
+const TrackChangeEmitter = require("resource://devtools/server/actors/utils/track-change-emitter.js");
+const {
+ getRuleText,
+ getTextAtLineColumn,
+} = require("resource://devtools/server/actors/utils/style-utils.js");
+
+const {
+ style: { ELEMENT_STYLE },
+} = require("resource://devtools/shared/constants.js");
+
+loader.lazyRequireGetter(
+ this,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "SharedCssLogic",
+ "resource://devtools/shared/inspector/css-logic.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "isCssPropertyKnown",
+ "resource://devtools/server/actors/css-properties.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "isPropertyUsed",
+ "resource://devtools/server/actors/utils/inactive-property-helper.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "parseNamedDeclarations",
+ "resource://devtools/shared/css/parsing-utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["UPDATE_PRESERVING_RULES", "UPDATE_GENERAL"],
+ "resource://devtools/server/actors/utils/stylesheets-manager.js",
+ true
+);
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * An actor that represents a CSS style object on the protocol.
+ *
+ * We slightly flatten the CSSOM for this actor, it represents
+ * both the CSSRule and CSSStyle objects in one actor. For nodes
+ * (which have a CSSStyle but no CSSRule) we create a StyleRuleActor
+ * with a special rule type (100).
+ */
+class StyleRuleActor extends Actor {
+ constructor(pageStyle, item, userAdded = false) {
+ super(pageStyle.conn, styleRuleSpec);
+ this.pageStyle = pageStyle;
+ this.rawStyle = item.style;
+ this._userAdded = userAdded;
+ this._parentSheet = null;
+ // Parsed CSS declarations from this.form().declarations used to check CSS property
+ // names and values before tracking changes. Using cached values instead of accessing
+ // this.form().declarations on demand because that would cause needless re-parsing.
+ this._declarations = [];
+
+ this._pendingDeclarationChanges = [];
+ this._failedToGetRuleText = false;
+
+ if (CSSRule.isInstance(item)) {
+ this.type = item.type;
+ this.ruleClassName = ChromeUtils.getClassName(item);
+
+ this.rawRule = item;
+ this._computeRuleIndex();
+ if (this.#isRuleSupported() && this.rawRule.parentStyleSheet) {
+ this.line = InspectorUtils.getRelativeRuleLine(this.rawRule);
+ this.column = InspectorUtils.getRuleColumn(this.rawRule);
+ this._parentSheet = this.rawRule.parentStyleSheet;
+ }
+ } else {
+ // Fake a rule
+ this.type = ELEMENT_STYLE;
+ this.ruleClassName = ELEMENT_STYLE;
+ this.rawNode = item;
+ this.rawRule = {
+ style: item.style,
+ toString() {
+ return "[element rule " + this.style + "]";
+ },
+ };
+ }
+ }
+
+ destroy() {
+ if (!this.rawStyle) {
+ return;
+ }
+ super.destroy();
+ this.rawStyle = null;
+ this.pageStyle = null;
+ this.rawNode = null;
+ this.rawRule = null;
+ this._declarations = null;
+ }
+
+ // Objects returned by this actor are owned by the PageStyleActor
+ // to which this rule belongs.
+ get marshallPool() {
+ return this.pageStyle;
+ }
+
+ // True if this rule supports as-authored styles, meaning that the
+ // rule text can be rewritten using setRuleText.
+ get canSetRuleText() {
+ if (this.type === ELEMENT_STYLE) {
+ // Element styles are always editable.
+ return true;
+ }
+ if (!this._parentSheet) {
+ return false;
+ }
+ if (InspectorUtils.hasRulesModifiedByCSSOM(this._parentSheet)) {
+ // If a rule has been modified via CSSOM, then we should fall back to
+ // non-authored editing.
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1224121
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Return an array with StyleRuleActor instances for each of this rule's ancestor rules
+ * (@media, @supports, @keyframes, etc) obtained by recursively reading rule.parentRule.
+ * If the rule has no ancestors, return an empty array.
+ *
+ * @return {Array}
+ */
+ get ancestorRules() {
+ const ancestors = [];
+ let rule = this.rawRule;
+
+ while (rule.parentRule) {
+ ancestors.unshift(this.pageStyle._styleRef(rule.parentRule));
+ rule = rule.parentRule;
+ }
+
+ return ancestors;
+ }
+
+ /**
+ * Return an object with information about this rule used for tracking changes.
+ * It will be decorated with information about a CSS change before being tracked.
+ *
+ * It contains:
+ * - the rule selector (or generated selectror for inline styles)
+ * - the rule's host stylesheet (or element for inline styles)
+ * - the rule's ancestor rules (@media, @supports, @keyframes), if any
+ * - the rule's position within its ancestor tree, if any
+ *
+ * @return {Object}
+ */
+ get metadata() {
+ const data = {};
+ data.id = this.actorID;
+ // Collect information about the rule's ancestors (@media, @supports, @keyframes, parent rules).
+ // Used to show context for this change in the UI and to match the rule for undo/redo.
+ data.ancestors = this.ancestorRules.map(rule => {
+ const ancestorData = {
+ id: rule.actorID,
+ // Array with the indexes of this rule and its ancestors within the CSS rule tree.
+ ruleIndex: rule._ruleIndex,
+ };
+
+ // Rule type as human-readable string (ex: "@media", "@supports", "@keyframes")
+ const typeName = SharedCssLogic.getCSSAtRuleTypeName(rule.rawRule);
+ if (typeName) {
+ ancestorData.typeName = typeName;
+ }
+
+ // Conditions of @container, @media and @supports rules (ex: "min-width: 1em")
+ if (rule.rawRule.conditionText !== undefined) {
+ ancestorData.conditionText = rule.rawRule.conditionText;
+ }
+
+ // Name of @keyframes rule; referenced by the animation-name CSS property.
+ if (rule.rawRule.name !== undefined) {
+ ancestorData.name = rule.rawRule.name;
+ }
+
+ // Selector of individual @keyframe rule within a @keyframes rule (ex: 0%, 100%).
+ if (rule.rawRule.keyText !== undefined) {
+ ancestorData.keyText = rule.rawRule.keyText;
+ }
+
+ // Selector of the rule; might be useful in case for nested rules
+ if (rule.rawRule.selectorText !== undefined) {
+ ancestorData.selectorText = rule.rawRule.selectorText;
+ }
+
+ return ancestorData;
+ });
+
+ // For changes in element style attributes, generate a unique selector.
+ if (this.type === ELEMENT_STYLE && this.rawNode) {
+ // findCssSelector() fails on XUL documents. Catch and silently ignore that error.
+ try {
+ data.selector = SharedCssLogic.findCssSelector(this.rawNode);
+ } catch (err) {}
+
+ data.source = {
+ type: "element",
+ // Used to differentiate between elements which match the same generated selector
+ // but live in different documents (ex: host document and iframe).
+ href: this.rawNode.baseURI,
+ // Element style attributes don't have a rule index; use the generated selector.
+ index: data.selector,
+ // Whether the element lives in a different frame than the host document.
+ isFramed: this.rawNode.ownerGlobal !== this.pageStyle.ownerWindow,
+ };
+
+ const nodeActor = this.pageStyle.walker.getNode(this.rawNode);
+ if (nodeActor) {
+ data.source.id = nodeActor.actorID;
+ }
+
+ data.ruleIndex = 0;
+ } else {
+ data.selector =
+ this.ruleClassName === "CSSKeyframeRule"
+ ? this.rawRule.keyText
+ : this.rawRule.selectorText;
+ // Used to differentiate between changes to rules with identical selectors.
+ data.ruleIndex = this._ruleIndex;
+
+ const sheet = this._parentSheet;
+ const inspectorActor = this.pageStyle.inspector;
+ const resourceId =
+ this.pageStyle.styleSheetsManager.getStyleSheetResourceId(sheet);
+ const styleSheetIndex =
+ this.pageStyle.styleSheetsManager.getStyleSheetIndex(resourceId);
+ data.source = {
+ // Inline stylesheets have a null href; Use window URL instead.
+ type: sheet.href ? "stylesheet" : "inline",
+ href: sheet.href || inspectorActor.window.location.toString(),
+ id: resourceId,
+ index: styleSheetIndex,
+ // Whether the stylesheet lives in a different frame than the host document.
+ isFramed: inspectorActor.window !== inspectorActor.window.top,
+ };
+ }
+
+ return data;
+ }
+
+ getDocument(sheet) {
+ if (!sheet.associatedDocument) {
+ throw new Error(
+ "Failed trying to get the document of an invalid stylesheet"
+ );
+ }
+ return sheet.associatedDocument;
+ }
+
+ /**
+ * When a rule is nested in another non-at-rule (aka CSS Nesting), the client
+ * will need its desugared selector, i.e. the full selector, which includes ancestor
+ * selectors, that is computed by the platform when applying the rule.
+ * To compute it, the parent selector (&) is recursively replaced by the parent
+ * rule selector wrapped in `:is()`.
+ * For example, with the following nested rule: `body { & > main {} }`,
+ * the desugared selector will be `:is(body) > main`.
+ * See https://www.w3.org/TR/css-nesting-1/#nest-selector for more information.
+ *
+ * Returns an array of the desugared selectors. For example, if rule is:
+ *
+ * body {
+ * & > main, & section {
+ * }
+ * }
+ *
+ * this will return:
+ *
+ * [
+ * `:is(body) > main`,
+ * `:is(body) section`,
+ * ]
+ *
+ * @returns Array<String>
+ */
+ getDesugaredSelectors() {
+ // Cache the desugared selectors as it can be expensive to compute
+ if (!this._desugaredSelectors) {
+ this._desugaredSelectors = CssLogic.getSelectors(this.rawRule, true);
+ }
+
+ return this._desugaredSelectors;
+ }
+
+ toString() {
+ return "[StyleRuleActor for " + this.rawRule + "]";
+ }
+
+ // eslint-disable-next-line complexity
+ form() {
+ const form = {
+ actor: this.actorID,
+ type: this.type,
+ line: this.line || undefined,
+ column: this.column,
+ traits: {
+ // Indicates whether StyleRuleActor implements and can use the setRuleText method.
+ // It cannot use it if the stylesheet was programmatically mutated via the CSSOM.
+ canSetRuleText: this.canSetRuleText,
+ },
+ };
+
+ // This rule was manually added by the user and may be automatically focused by the frontend.
+ if (this._userAdded) {
+ form.userAdded = true;
+ }
+
+ const { computeDesugaredSelector, ancestorData } =
+ this._getAncestorDataForForm();
+ form.ancestorData = ancestorData;
+
+ if (this._parentSheet) {
+ form.parentStyleSheet =
+ this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
+ this._parentSheet
+ );
+ }
+
+ // One tricky thing here is that other methods in this actor must
+ // ensure that authoredText has been set before |form| is called.
+ // This has to be treated specially, for now, because we cannot
+ // synchronously compute the authored text, but |form| also cannot
+ // return a promise. See bug 1205868.
+ form.authoredText = this.authoredText;
+
+ switch (this.ruleClassName) {
+ case "CSSStyleRule":
+ form.selectors = CssLogic.getSelectors(this.rawRule);
+
+ // Only add the property when there are elements in the array to save up on serialization.
+ const selectorWarnings = this.rawRule.getSelectorWarnings();
+ if (selectorWarnings.length) {
+ form.selectorWarnings = selectorWarnings;
+ }
+ if (computeDesugaredSelector) {
+ form.desugaredSelectors = this.getDesugaredSelectors();
+ }
+ form.cssText = this.rawStyle.cssText || "";
+ break;
+ case ELEMENT_STYLE:
+ // Elements don't have a parent stylesheet, and therefore
+ // don't have an associated URI. Provide a URI for
+ // those.
+ const doc = this.rawNode.ownerDocument;
+ form.href = doc.location ? doc.location.href : "";
+ form.cssText = this.rawStyle.cssText || "";
+ form.authoredText = this.rawNode.getAttribute("style");
+ break;
+ case "CSSCharsetRule":
+ form.encoding = this.rawRule.encoding;
+ break;
+ case "CSSImportRule":
+ form.href = this.rawRule.href;
+ break;
+ case "CSSKeyframesRule":
+ form.cssText = this.rawRule.cssText;
+ form.name = this.rawRule.name;
+ break;
+ case "CSSKeyframeRule":
+ form.cssText = this.rawStyle.cssText || "";
+ form.keyText = this.rawRule.keyText || "";
+ break;
+ }
+
+ // Parse the text into a list of declarations so the client doesn't have to
+ // and so that we can safely determine if a declaration is valid rather than
+ // have the client guess it.
+ if (form.authoredText || form.cssText) {
+ // authoredText may be an empty string when deleting all properties; it's ok to use.
+ const cssText =
+ typeof form.authoredText === "string"
+ ? form.authoredText
+ : form.cssText;
+ const declarations = parseNamedDeclarations(
+ isCssPropertyKnown,
+ cssText,
+ true
+ );
+ const el = this.pageStyle.selectedElement;
+ const style = this.pageStyle.cssLogic.computedStyle;
+
+ // Whether the stylesheet is a user-agent stylesheet. This affects the
+ // validity of some properties and property values.
+ const userAgent =
+ this._parentSheet &&
+ SharedCssLogic.isAgentStylesheet(this._parentSheet);
+ // Whether the stylesheet is a chrome stylesheet. Ditto.
+ //
+ // Note that chrome rules are also enabled in user sheets, see
+ // ParserContext::chrome_rules_enabled().
+ //
+ // https://searchfox.org/mozilla-central/rev/919607a3610222099fbfb0113c98b77888ebcbfb/servo/components/style/parser.rs#164
+ const chrome = (() => {
+ if (!this._parentSheet) {
+ return false;
+ }
+ if (SharedCssLogic.isUserStylesheet(this._parentSheet)) {
+ return true;
+ }
+ if (this._parentSheet.href) {
+ return this._parentSheet.href.startsWith("chrome:");
+ }
+ return el && el.ownerDocument.documentURI.startsWith("chrome:");
+ })();
+ // Whether the document is in quirks mode. This affects whether stuff
+ // like `width: 10` is valid.
+ const quirks =
+ !userAgent && el && el.ownerDocument.compatMode == "BackCompat";
+ const supportsOptions = { userAgent, chrome, quirks };
+ form.declarations = declarations.map(decl => {
+ // InspectorUtils.supports only supports the 1-arg version, but that's
+ // what we want to do anyways so that we also accept !important in the
+ // value.
+ decl.isValid = InspectorUtils.supports(
+ `${decl.name}:${decl.value}`,
+ supportsOptions
+ );
+ // TODO: convert from Object to Boolean. See Bug 1574471
+ decl.isUsed = isPropertyUsed(el, style, this.rawRule, decl.name);
+ // Check property name. All valid CSS properties support "initial" as a value.
+ decl.isNameValid = InspectorUtils.supports(
+ `${decl.name}:initial`,
+ supportsOptions
+ );
+
+ if (SharedCssLogic.isCssVariable(decl.name)) {
+ decl.isCustomProperty = true;
+ // We only compute `inherits` for css variable declarations.
+ // For "regular" declaration, we use `CssPropertiesFront.isInherited`,
+ // which doesn't depend on the state of the document (a given property will
+ // always have the same isInherited value).
+ // CSS variables on the other hand can be registered custom properties (e.g.,
+ // `@property`/`CSS.registerProperty`), with a `inherits` definition that can
+ // be true or false.
+ // As such custom properties can be registered at any time during the page
+ // lifecycle, we always recompute the `inherits` information for CSS variables.
+ decl.inherits = InspectorUtils.isInheritedProperty(
+ this.pageStyle.inspector.window.document,
+ decl.name
+ );
+ }
+
+ return decl;
+ });
+
+ // We have computed the new `declarations` array, before forgetting about
+ // the old declarations compute the CSS changes for pending modifications
+ // applied by the user. Comparing the old and new declarations arrays
+ // ensures we only rely on values understood by the engine and not authored
+ // values. See Bug 1590031.
+ this._pendingDeclarationChanges.forEach(change =>
+ this.logDeclarationChange(change, declarations, this._declarations)
+ );
+ this._pendingDeclarationChanges = [];
+
+ // Cache parsed declarations so we don't needlessly re-parse authoredText every time
+ // we need to check previous property names and values when tracking changes.
+ this._declarations = declarations;
+ }
+
+ return form;
+ }
+
+ /**
+ *
+ * @returns {Object} Object with the following properties:
+ * - {Array<Object>} ancestorData: An array of ancestor item data
+ * - {Boolean} computeDesugaredSelector: true if the rule has a non-at-rule
+ * parent rule (i.e. rule is likely to be a nested rule)
+ */
+ _getAncestorDataForForm() {
+ const ancestorData = [];
+ // Flag that will be set to true if the rule has a non-at-rule parent rule
+ let computeDesugaredSelector = false;
+
+ // Go through all ancestor so we can build an array of all the media queries and
+ // layers this rule is in.
+ for (const ancestorRule of this.ancestorRules) {
+ const rawRule = ancestorRule.rawRule;
+ const ruleClassName = ChromeUtils.getClassName(rawRule);
+ const type = SharedCssLogic.CSSAtRuleClassNameType[ruleClassName];
+
+ if (ruleClassName === "CSSMediaRule" && rawRule.media?.length) {
+ ancestorData.push({
+ type,
+ value: Array.from(rawRule.media).join(", "),
+ });
+ } else if (ruleClassName === "CSSLayerBlockRule") {
+ ancestorData.push({
+ // we need the actorID so we can uniquely identify nameless layers on the client
+ actorID: ancestorRule.actorID,
+ type,
+ value: rawRule.name,
+ });
+ } else if (ruleClassName === "CSSContainerRule") {
+ ancestorData.push({
+ type,
+ // Send containerName and containerQuery separately (instead of conditionText)
+ // so the client has more flexibility to display the information.
+ containerName: rawRule.containerName,
+ containerQuery: rawRule.containerQuery,
+ });
+ } else if (ruleClassName === "CSSSupportsRule") {
+ ancestorData.push({
+ type,
+ conditionText: rawRule.conditionText,
+ });
+ } else if (rawRule.selectorText) {
+ // All the previous cases where about at-rules; this one is for regular rule
+ // that are ancestors because CSS nesting was used.
+ // In such case, we want to return the selectorText so it can be displayed in the UI.
+ const ancestor = {
+ type,
+ selectors: CssLogic.getSelectors(rawRule),
+ };
+
+ // Only add the property when there are elements in the array to save up on serialization.
+ const selectorWarnings = rawRule.getSelectorWarnings();
+ if (selectorWarnings.length) {
+ ancestor.selectorWarnings = selectorWarnings;
+ }
+
+ ancestorData.push(ancestor);
+ computeDesugaredSelector = true;
+ }
+ }
+
+ if (this._parentSheet) {
+ // Loop through all parent stylesheets to get the whole list of @import rules.
+ let rule = this.rawRule;
+ while ((rule = rule.parentStyleSheet?.ownerRule)) {
+ // If the rule is in a imported stylesheet with a specified layer
+ if (rule.layerName !== null) {
+ // Put the item at the top of the ancestor data array, as we're going up
+ // in the stylesheet hierarchy, and we want to display ancestor rules in the
+ // orders they're applied.
+ ancestorData.unshift({
+ type: "layer",
+ value: rule.layerName,
+ });
+ }
+
+ // If the rule is in a imported stylesheet with specified media/supports conditions
+ if (rule.media?.mediaText || rule.supportsText) {
+ const parts = [];
+ if (rule.supportsText) {
+ parts.push(`supports(${rule.supportsText})`);
+ }
+
+ if (rule.media?.mediaText) {
+ parts.push(rule.media.mediaText);
+ }
+
+ // Put the item at the top of the ancestor data array, as we're going up
+ // in the stylesheet hierarchy, and we want to display ancestor rules in the
+ // orders they're applied.
+ ancestorData.unshift({
+ type: "import",
+ value: parts.join(" "),
+ });
+ }
+ }
+ }
+ return { ancestorData, computeDesugaredSelector };
+ }
+
+ /**
+ * Send an event notifying that the location of the rule has
+ * changed.
+ *
+ * @param {Number} line the new line number
+ * @param {Number} column the new column number
+ */
+ _notifyLocationChanged(line, column) {
+ this.emit("location-changed", line, column);
+ }
+
+ /**
+ * Compute the index of this actor's raw rule in its parent style
+ * sheet. The index is a vector where each element is the index of
+ * a given CSS rule in its parent. A vector is used to support
+ * nested rules.
+ */
+ _computeRuleIndex() {
+ const index = InspectorUtils.getRuleIndex(this.rawRule);
+ this._ruleIndex = index.length ? index : null;
+ }
+
+ /**
+ * Get the rule corresponding to |this._ruleIndex| from the given
+ * style sheet.
+ *
+ * @param {DOMStyleSheet} sheet
+ * The style sheet.
+ * @return {CSSStyleRule} the rule corresponding to
+ * |this._ruleIndex|
+ */
+ _getRuleFromIndex(parentSheet) {
+ let currentRule = null;
+ for (const i of this._ruleIndex) {
+ if (currentRule === null) {
+ currentRule = parentSheet.cssRules[i];
+ } else {
+ currentRule = currentRule.cssRules.item(i);
+ }
+ }
+ return currentRule;
+ }
+
+ /**
+ * Called from PageStyle actor _onStylesheetUpdated.
+ */
+ onStyleApplied(kind) {
+ if (kind === UPDATE_GENERAL) {
+ // A general change means that the rule actors are invalidated, nothing
+ // to do here.
+ return;
+ }
+
+ if (this._ruleIndex) {
+ // The sheet was updated by this actor, in a way that preserves
+ // the rules. Now, recompute our new rule from the style sheet,
+ // so that we aren't left with a reference to a dangling rule.
+ const oldRule = this.rawRule;
+ const oldActor = this.pageStyle.refMap.get(oldRule);
+ this.rawRule = this._getRuleFromIndex(this._parentSheet);
+ if (oldActor) {
+ // Also tell the page style so that future calls to _styleRef
+ // return the same StyleRuleActor.
+ this.pageStyle.updateStyleRef(oldRule, this.rawRule, this);
+ }
+ const line = InspectorUtils.getRelativeRuleLine(this.rawRule);
+ const column = InspectorUtils.getRuleColumn(this.rawRule);
+ if (line !== this.line || column !== this.column) {
+ this._notifyLocationChanged(line, column);
+ }
+ this.line = line;
+ this.column = column;
+ }
+ }
+
+ #SUPPORTED_RULES_CLASSNAMES = new Set([
+ "CSSContainerRule",
+ "CSSKeyframeRule",
+ "CSSKeyframesRule",
+ "CSSLayerBlockRule",
+ "CSSMediaRule",
+ "CSSStyleRule",
+ "CSSSupportsRule",
+ ]);
+
+ #isRuleSupported() {
+ // this.rawRule might not be an actual CSSRule (e.g. when this represent an element style),
+ // and in such case, ChromeUtils.getClassName will throw
+ try {
+ const ruleClassName = ChromeUtils.getClassName(this.rawRule);
+ return this.#SUPPORTED_RULES_CLASSNAMES.has(ruleClassName);
+ } catch (e) {}
+
+ return false;
+ }
+
+ /**
+ * Return a promise that resolves to the authored form of a rule's
+ * text, if available. If the authored form is not available, the
+ * returned promise simply resolves to the empty string. If the
+ * authored form is available, this also sets |this.authoredText|.
+ * The authored text will include invalid and otherwise ignored
+ * properties.
+ *
+ * @param {Boolean} skipCache
+ * If a value for authoredText was previously found and cached,
+ * ignore it and parse the stylehseet again. The authoredText
+ * may be outdated if a descendant of this rule has changed.
+ */
+ async getAuthoredCssText(skipCache = false) {
+ if (!this.canSetRuleText || !this.#isRuleSupported()) {
+ return "";
+ }
+
+ if (!skipCache) {
+ if (this._failedToGetRuleText) {
+ return "";
+ }
+ if (typeof this.authoredText === "string") {
+ return this.authoredText;
+ }
+ }
+
+ try {
+ const resourceId =
+ this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
+ this._parentSheet
+ );
+ const cssText = await this.pageStyle.styleSheetsManager.getText(
+ resourceId
+ );
+ const { text } = getRuleText(cssText, this.line, this.column);
+ // Cache the result on the rule actor to avoid parsing again next time
+ this._failedToGetRuleText = false;
+ this.authoredText = text;
+ } catch (e) {
+ this._failedToGetRuleText = true;
+ this.authoredText = undefined;
+ return "";
+ }
+ return this.authoredText;
+ }
+
+ /**
+ * Return a promise that resolves to the complete cssText of the rule as authored.
+ *
+ * Unlike |getAuthoredCssText()|, which only returns the contents of the rule, this
+ * method includes the CSS selectors and at-rules (@media, @supports, @keyframes, etc.)
+ *
+ * If the rule type is unrecongized, the promise resolves to an empty string.
+ * If the rule is an element inline style, the promise resolves with the generated
+ * selector that uniquely identifies the element and with the rule body consisting of
+ * the element's style attribute.
+ *
+ * @return {String}
+ */
+ async getRuleText() {
+ // Bail out if the rule is not supported or not an element inline style.
+ if (!this.#isRuleSupported(true) && this.type !== ELEMENT_STYLE) {
+ return "";
+ }
+
+ let ruleBodyText;
+ let selectorText;
+
+ // For element inline styles, use the style attribute and generated unique selector.
+ if (this.type === ELEMENT_STYLE) {
+ ruleBodyText = this.rawNode.getAttribute("style");
+ selectorText = this.metadata.selector;
+ } else {
+ // Get the rule's authored text and skip any cached value.
+ ruleBodyText = await this.getAuthoredCssText(true);
+
+ const resourceId =
+ this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
+ this._parentSheet
+ );
+ const stylesheetText = await this.pageStyle.styleSheetsManager.getText(
+ resourceId
+ );
+
+ const [start, end] = getSelectorOffsets(
+ stylesheetText,
+ this.line,
+ this.column
+ );
+ selectorText = stylesheetText.substring(start, end);
+ }
+
+ const text = `${selectorText} {${ruleBodyText}}`;
+ const { result } = SharedCssLogic.prettifyCSS(text);
+ return result;
+ }
+
+ /**
+ * Set the contents of the rule. This rewrites the rule in the
+ * stylesheet and causes it to be re-evaluated.
+ *
+ * @param {String} newText
+ * The new text of the rule
+ * @param {Array} modifications
+ * Array with modifications applied to the rule. Contains objects like:
+ * {
+ * type: "set",
+ * index: <number>,
+ * name: <string>,
+ * value: <string>,
+ * priority: <optional string>
+ * }
+ * or
+ * {
+ * type: "remove",
+ * index: <number>,
+ * name: <string>,
+ * }
+ * @returns the rule with updated properties
+ */
+ async setRuleText(newText, modifications = []) {
+ if (!this.canSetRuleText) {
+ throw new Error("invalid call to setRuleText");
+ }
+
+ if (this.type === ELEMENT_STYLE) {
+ // For element style rules, set the node's style attribute.
+ this.rawNode.setAttributeDevtools("style", newText);
+ } else {
+ const resourceId =
+ this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
+ this._parentSheet
+ );
+ let cssText = await this.pageStyle.styleSheetsManager.getText(resourceId);
+
+ const { offset, text } = getRuleText(cssText, this.line, this.column);
+ cssText =
+ cssText.substring(0, offset) +
+ newText +
+ cssText.substring(offset + text.length);
+
+ await this.pageStyle.styleSheetsManager.setStyleSheetText(
+ resourceId,
+ cssText,
+ { kind: UPDATE_PRESERVING_RULES }
+ );
+ }
+
+ this.authoredText = newText;
+ await this.updateAncestorRulesAuthoredText();
+ this.pageStyle.refreshObservedRules(this.ancestorRules);
+
+ // Add processed modifications to the _pendingDeclarationChanges array,
+ // they will be emitted as CSS_CHANGE resources once `declarations` have
+ // been re-computed in `form`.
+ this._pendingDeclarationChanges.push(...modifications);
+
+ // Returning this updated actor over the protocol will update its corresponding front
+ // and any references to it.
+ return this;
+ }
+
+ /**
+ * Update the authored text of the ancestor rules. This should be called when setting
+ * the authored text of a (nested) rule, so all the references are properly updated.
+ */
+ async updateAncestorRulesAuthoredText() {
+ return Promise.all(
+ this.ancestorRules.map(rule => rule.getAuthoredCssText(true))
+ );
+ }
+
+ /**
+ * Modify a rule's properties. Passed an array of modifications:
+ * {
+ * type: "set",
+ * index: <number>,
+ * name: <string>,
+ * value: <string>,
+ * priority: <optional string>
+ * }
+ * or
+ * {
+ * type: "remove",
+ * index: <number>,
+ * name: <string>,
+ * }
+ *
+ * @returns the rule with updated properties
+ */
+ modifyProperties(modifications) {
+ // Use a fresh element for each call to this function to prevent side
+ // effects that pop up based on property values that were already set on the
+ // element.
+ let document;
+ if (this.rawNode) {
+ document = this.rawNode.ownerDocument;
+ } else {
+ let parentStyleSheet = this._parentSheet;
+ while (parentStyleSheet.ownerRule) {
+ parentStyleSheet = parentStyleSheet.ownerRule.parentStyleSheet;
+ }
+
+ document = this.getDocument(parentStyleSheet);
+ }
+
+ const tempElement = document.createElementNS(XHTML_NS, "div");
+
+ for (const mod of modifications) {
+ if (mod.type === "set") {
+ tempElement.style.setProperty(mod.name, mod.value, mod.priority || "");
+ this.rawStyle.setProperty(
+ mod.name,
+ tempElement.style.getPropertyValue(mod.name),
+ mod.priority || ""
+ );
+ } else if (mod.type === "remove" || mod.type === "disable") {
+ this.rawStyle.removeProperty(mod.name);
+ }
+ }
+
+ this.pageStyle.refreshObservedRules(this.ancestorRules);
+
+ // Add processed modifications to the _pendingDeclarationChanges array,
+ // they will be emitted as CSS_CHANGE resources once `declarations` have
+ // been re-computed in `form`.
+ this._pendingDeclarationChanges.push(...modifications);
+
+ return this;
+ }
+
+ /**
+ * Helper function for modifySelector, inserts the new
+ * rule with the new selector into the parent style sheet and removes the
+ * current rule. Returns the newly inserted css rule or null if the rule is
+ * unsuccessfully inserted to the parent style sheet.
+ *
+ * @param {String} value
+ * The new selector value
+ * @param {Boolean} editAuthored
+ * True if the selector should be updated by editing the
+ * authored text; false if the selector should be updated via
+ * CSSOM.
+ *
+ * @returns {CSSRule}
+ * The new CSS rule added
+ */
+ async _addNewSelector(value, editAuthored) {
+ const rule = this.rawRule;
+ const parentStyleSheet = this._parentSheet;
+
+ // We know the selector modification is ok, so if the client asked
+ // for the authored text to be edited, do it now.
+ if (editAuthored) {
+ const document = this.getDocument(this._parentSheet);
+ try {
+ document.querySelector(value);
+ } catch (e) {
+ return null;
+ }
+
+ const resourceId =
+ this.pageStyle.styleSheetsManager.getStyleSheetResourceId(
+ this._parentSheet
+ );
+ let authoredText = await this.pageStyle.styleSheetsManager.getText(
+ resourceId
+ );
+
+ const [startOffset, endOffset] = getSelectorOffsets(
+ authoredText,
+ this.line,
+ this.column
+ );
+ authoredText =
+ authoredText.substring(0, startOffset) +
+ value +
+ authoredText.substring(endOffset);
+
+ await this.pageStyle.styleSheetsManager.setStyleSheetText(
+ resourceId,
+ authoredText,
+ { kind: UPDATE_PRESERVING_RULES }
+ );
+ } else {
+ // We retrieve the parent of the rule, which can be a regular stylesheet, but also
+ // another rule, in case the underlying rule is nested.
+ // If the rule is nested in another rule, we need to use its parent rule to "edit" it.
+ // If the rule has no parent rules, we can simply use the stylesheet.
+ const parent = this.rawRule.parentRule || parentStyleSheet;
+ const cssRules = parent.cssRules;
+ const cssText = rule.cssText;
+ const selectorText = rule.selectorText;
+
+ for (let i = 0; i < cssRules.length; i++) {
+ if (rule === cssRules.item(i)) {
+ try {
+ // Inserts the new style rule into the current style sheet and
+ // delete the current rule
+ const ruleText = cssText.slice(selectorText.length).trim();
+ parent.insertRule(value + " " + ruleText, i);
+ parent.deleteRule(i + 1);
+ break;
+ } catch (e) {
+ // The selector could be invalid, or the rule could fail to insert.
+ return null;
+ }
+ }
+ }
+ }
+
+ await this.updateAncestorRulesAuthoredText();
+
+ return this._getRuleFromIndex(parentStyleSheet);
+ }
+
+ /**
+ * Take an object with instructions to modify a CSS declaration and log an object with
+ * normalized metadata which describes the change in the context of this rule.
+ *
+ * @param {Object} change
+ * Data about a modification to a declaration. @see |modifyProperties()|
+ * @param {Object} newDeclarations
+ * The current declarations array to get the latest values, names...
+ * @param {Object} oldDeclarations
+ * The previous declarations array to use to fetch old values, names...
+ */
+ logDeclarationChange(change, newDeclarations, oldDeclarations) {
+ // Position of the declaration within its rule.
+ const index = change.index;
+ // Destructure properties from the previous CSS declaration at this index, if any,
+ // to new variable names to indicate the previous state.
+ let {
+ value: prevValue,
+ name: prevName,
+ priority: prevPriority,
+ commentOffsets,
+ } = oldDeclarations[index] || {};
+
+ const { value: currentValue, name: currentName } =
+ newDeclarations[index] || {};
+ // A declaration is disabled if it has a `commentOffsets` array.
+ // Here we type coerce the value to a boolean with double-bang (!!)
+ const prevDisabled = !!commentOffsets;
+ // Append the "!important" string if defined in the previous priority flag.
+ prevValue =
+ prevValue && prevPriority ? `${prevValue} !important` : prevValue;
+
+ const data = this.metadata;
+
+ switch (change.type) {
+ case "set":
+ data.type = prevValue ? "declaration-add" : "declaration-update";
+ // If `change.newName` is defined, use it because the property is being renamed.
+ // Otherwise, a new declaration is being created or the value of an existing
+ // declaration is being updated. In that case, use the currentName computed
+ // by the engine.
+ const changeName = currentName || change.name;
+ const name = change.newName ? change.newName : changeName;
+ // Append the "!important" string if defined in the incoming priority flag.
+
+ const changeValue = currentValue || change.value;
+ const newValue = change.priority
+ ? `${changeValue} !important`
+ : changeValue;
+
+ // Reuse the previous value string, when the property is renamed.
+ // Otherwise, use the incoming value string.
+ const value = change.newName ? prevValue : newValue;
+
+ data.add = [{ property: name, value, index }];
+ // If there is a previous value, log its removal together with the previous
+ // property name. Using the previous name handles the case for renaming a property
+ // and is harmless when updating an existing value (the name stays the same).
+ if (prevValue) {
+ data.remove = [{ property: prevName, value: prevValue, index }];
+ } else {
+ data.remove = null;
+ }
+
+ // When toggling a declaration from OFF to ON, if not renaming the property,
+ // do not mark the previous declaration for removal, otherwise the add and
+ // remove operations will cancel each other out when tracked. Tracked changes
+ // have no context of "disabled", only "add" or remove, like diffs.
+ if (prevDisabled && !change.newName && prevValue === newValue) {
+ data.remove = null;
+ }
+
+ break;
+
+ case "remove":
+ data.type = "declaration-remove";
+ data.add = null;
+ data.remove = [{ property: change.name, value: prevValue, index }];
+ break;
+
+ case "disable":
+ data.type = "declaration-disable";
+ data.add = null;
+ data.remove = [{ property: change.name, value: prevValue, index }];
+ break;
+ }
+
+ TrackChangeEmitter.trackChange(data);
+ }
+
+ /**
+ * Helper method for tracking CSS changes. Logs the change of this rule's selector as
+ * two operations: a removal using the old selector and an addition using the new one.
+ *
+ * @param {String} oldSelector
+ * This rule's previous selector.
+ * @param {String} newSelector
+ * This rule's new selector.
+ */
+ logSelectorChange(oldSelector, newSelector) {
+ TrackChangeEmitter.trackChange({
+ ...this.metadata,
+ type: "selector-remove",
+ add: null,
+ remove: null,
+ selector: oldSelector,
+ });
+
+ TrackChangeEmitter.trackChange({
+ ...this.metadata,
+ type: "selector-add",
+ add: null,
+ remove: null,
+ selector: newSelector,
+ });
+ }
+
+ /**
+ * Modify the current rule's selector by inserting a new rule with the new
+ * selector value and removing the current rule.
+ *
+ * Returns information about the new rule and applied style
+ * so that consumers can immediately display the new rule, whether or not the
+ * selector matches the current element without having to refresh the whole
+ * list.
+ *
+ * @param {DOMNode} node
+ * The current selected element
+ * @param {String} value
+ * The new selector value
+ * @param {Boolean} editAuthored
+ * True if the selector should be updated by editing the
+ * authored text; false if the selector should be updated via
+ * CSSOM.
+ * @returns {Object}
+ * Returns an object that contains the applied style properties of the
+ * new rule and a boolean indicating whether or not the new selector
+ * matches the current selected element
+ */
+ modifySelector(node, value, editAuthored = false) {
+ if (this.type === ELEMENT_STYLE || this.rawRule.selectorText === value) {
+ return { ruleProps: null, isMatching: true };
+ }
+
+ // Nullify cached desugared selectors as it might be outdated
+ this._desugaredSelectors = null;
+
+ // The rule's previous selector is lost after calling _addNewSelector(). Save it now.
+ const oldValue = this.rawRule.selectorText;
+ let selectorPromise = this._addNewSelector(value, editAuthored);
+
+ if (editAuthored) {
+ selectorPromise = selectorPromise.then(newCssRule => {
+ if (newCssRule) {
+ this.logSelectorChange(oldValue, value);
+ const style = this.pageStyle._styleRef(newCssRule);
+ // See the comment in |form| to understand this.
+ return style.getAuthoredCssText().then(() => newCssRule);
+ }
+ return newCssRule;
+ });
+ }
+
+ return selectorPromise.then(newCssRule => {
+ let entries = null;
+ let isMatching = false;
+
+ if (newCssRule) {
+ const ruleEntry = this.pageStyle.findEntryMatchingRule(
+ node,
+ newCssRule
+ );
+ if (ruleEntry.length === 1) {
+ entries = this.pageStyle.getAppliedProps(node, ruleEntry, {
+ matchedSelectors: true,
+ });
+ } else {
+ entries = this.pageStyle.getNewAppliedProps(node, newCssRule);
+ }
+
+ isMatching = entries.some(
+ ruleProp => !!ruleProp.matchedDesugaredSelectors.length
+ );
+ }
+
+ const result = { isMatching };
+ if (entries) {
+ result.ruleProps = { entries };
+ }
+
+ return result;
+ });
+ }
+
+ /**
+ * Get the eligible query container for a given @container rule and a given node
+ *
+ * @param {Number} ancestorRuleIndex: The index of the @container rule in this.ancestorRules
+ * @param {NodeActor} nodeActor: The nodeActor for which we want to retrieve the query container
+ * @returns {Object} An object with the following properties:
+ * - node: {NodeActor|null} The nodeActor representing the query container,
+ * null if none were found
+ * - containerType: {string} The computed `containerType` value of the query container
+ * - inlineSize: {string} The computed `inlineSize` value of the query container (e.g. `120px`)
+ * - blockSize: {string} The computed `blockSize` value of the query container (e.g. `812px`)
+ */
+ getQueryContainerForNode(ancestorRuleIndex, nodeActor) {
+ const ancestorRule = this.ancestorRules[ancestorRuleIndex];
+ if (!ancestorRule) {
+ console.error(
+ `Couldn't not find an ancestor rule at index ${ancestorRuleIndex}`
+ );
+ return { node: null };
+ }
+
+ const containerEl = ancestorRule.rawRule.queryContainerFor(
+ nodeActor.rawNode
+ );
+
+ // queryContainerFor returns null when the container name wasn't find in any ancestor.
+ // In practice this shouldn't happen, as if the rule is applied, it means that an
+ // elligible container was found.
+ if (!containerEl) {
+ return { node: null };
+ }
+
+ const computedStyle = CssLogic.getComputedStyle(containerEl);
+ return {
+ node: this.pageStyle.walker.getNode(containerEl),
+ containerType: computedStyle.containerType,
+ inlineSize: computedStyle.inlineSize,
+ blockSize: computedStyle.blockSize,
+ };
+ }
+
+ /**
+ * Using the latest computed style applicable to the selected element,
+ * check the states of declarations in this CSS rule.
+ *
+ * If any have changed their used/unused state, potentially as a result of changes in
+ * another rule, fire a "rule-updated" event with this rule actor in its latest state.
+ *
+ * @param {Boolean} forceRefresh: Set to true to emit "rule-updated", even if the state
+ * of the declarations didn't change.
+ */
+ maybeRefresh(forceRefresh) {
+ let hasChanged = false;
+
+ const el = this.pageStyle.selectedElement;
+ const style = CssLogic.getComputedStyle(el);
+
+ for (const decl of this._declarations) {
+ // TODO: convert from Object to Boolean. See Bug 1574471
+ const isUsed = isPropertyUsed(el, style, this.rawRule, decl.name);
+
+ if (decl.isUsed.used !== isUsed.used) {
+ decl.isUsed = isUsed;
+ hasChanged = true;
+ }
+ }
+
+ if (hasChanged || forceRefresh) {
+ // ⚠️ IMPORTANT ⚠️
+ // When an event is emitted via the protocol with the StyleRuleActor as payload, the
+ // corresponding StyleRuleFront will be automatically updated under the hood.
+ // Therefore, when the client looks up properties on the front reference it already
+ // has, it will get the latest values set on the actor, not the ones it originally
+ // had when the front was created. The client is not required to explicitly replace
+ // its previous front reference to the one it receives as this event's payload.
+ // The client doesn't even need to explicitly listen for this event.
+ // The update of the front happens automatically.
+ this.emit("rule-updated", this);
+ }
+ }
+}
+exports.StyleRuleActor = StyleRuleActor;
+
+/**
+ * Compute the start and end offsets of a rule's selector text, given
+ * the CSS text and the line and column at which the rule begins.
+ * @param {String} initialText
+ * @param {Number} line (1-indexed)
+ * @param {Number} column (1-indexed)
+ * @return {array} An array with two elements: [startOffset, endOffset].
+ * The elements mark the bounds in |initialText| of
+ * the CSS rule's selector.
+ */
+function getSelectorOffsets(initialText, line, column) {
+ if (typeof line === "undefined" || typeof column === "undefined") {
+ throw new Error("Location information is missing");
+ }
+
+ const { offset: textOffset, text } = getTextAtLineColumn(
+ initialText,
+ line,
+ column
+ );
+ const lexer = getCSSLexer(text);
+
+ // Search forward for the opening brace.
+ let endOffset;
+ while (true) {
+ const token = lexer.nextToken();
+ if (!token) {
+ break;
+ }
+ if (token.tokenType === "symbol" && token.text === "{") {
+ if (endOffset === undefined) {
+ break;
+ }
+ return [textOffset, textOffset + endOffset];
+ }
+ // Preserve comments and whitespace just before the "{".
+ if (token.tokenType !== "comment" && token.tokenType !== "whitespace") {
+ endOffset = token.endOffset;
+ }
+ }
+
+ throw new Error("could not find bounds of rule");
+}
diff --git a/devtools/server/actors/style-sheets.js b/devtools/server/actors/style-sheets.js
new file mode 100644
index 0000000000..64f16badc0
--- /dev/null
+++ b/devtools/server/actors/style-sheets.js
@@ -0,0 +1,105 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ styleSheetsSpec,
+} = require("resource://devtools/shared/specs/style-sheets.js");
+
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+
+loader.lazyRequireGetter(
+ this,
+ "UPDATE_GENERAL",
+ "resource://devtools/server/actors/utils/stylesheets-manager.js",
+ true
+);
+
+/**
+ * Creates a StyleSheetsActor. StyleSheetsActor provides remote access to the
+ * stylesheets of a document.
+ */
+class StyleSheetsActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, styleSheetsSpec);
+
+ this.parentActor = targetActor;
+ }
+
+ /**
+ * The window we work with, taken from the parent actor.
+ */
+ get window() {
+ return this.parentActor.window;
+ }
+
+ /**
+ * The current content document of the window we work with.
+ */
+ get document() {
+ return this.window.document;
+ }
+
+ getTraits() {
+ return {
+ traits: {},
+ };
+ }
+
+ destroy() {
+ for (const win of this.parentActor.windows) {
+ // This flag only exists for devtools, so we are free to clear
+ // it when we're done.
+ win.document.styleSheetChangeEventsEnabled = false;
+ }
+
+ super.destroy();
+ }
+
+ /**
+ * Create a new style sheet in the document with the given text.
+ * Return an actor for it.
+ *
+ * @param {object} request
+ * Debugging protocol request object, with 'text property'
+ * @param {string} fileName
+ * If the stylesheet adding is from file, `fileName` indicates the path.
+ * @return {object}
+ * Object with 'styelSheet' property for form on new actor.
+ */
+ async addStyleSheet(text, fileName = null) {
+ const styleSheetsManager = this._getStyleSheetsManager();
+ await styleSheetsManager.addStyleSheet(this.document, text, fileName);
+ }
+
+ _getStyleSheetsManager() {
+ return this.parentActor.getStyleSheetsManager();
+ }
+
+ toggleDisabled(resourceId) {
+ const styleSheetsManager = this._getStyleSheetsManager();
+ return styleSheetsManager.toggleDisabled(resourceId);
+ }
+
+ async getText(resourceId) {
+ const styleSheetsManager = this._getStyleSheetsManager();
+ const text = await styleSheetsManager.getText(resourceId);
+ return new LongStringActor(this.conn, text || "");
+ }
+
+ update(resourceId, text, transition, cause = "") {
+ const styleSheetsManager = this._getStyleSheetsManager();
+ return styleSheetsManager.setStyleSheetText(resourceId, text, {
+ transition,
+ kind: UPDATE_GENERAL,
+ cause,
+ });
+ }
+}
+
+exports.StyleSheetsActor = StyleSheetsActor;
diff --git a/devtools/server/actors/target-configuration.js b/devtools/server/actors/target-configuration.js
new file mode 100644
index 0000000000..35340ee668
--- /dev/null
+++ b/devtools/server/actors/target-configuration.js
@@ -0,0 +1,493 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ targetConfigurationSpec,
+} = require("resource://devtools/shared/specs/target-configuration.js");
+
+const {
+ SessionDataHelpers,
+} = require("resource://devtools/server/actors/watcher/SessionDataHelpers.jsm");
+const { isBrowsingContextPartOfContext } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs"
+);
+const { SUPPORTED_DATA } = SessionDataHelpers;
+const { TARGET_CONFIGURATION } = SUPPORTED_DATA;
+
+// List of options supported by this target configuration actor.
+/* eslint sort-keys: "error" */
+const SUPPORTED_OPTIONS = {
+ // Disable network request caching.
+ cacheDisabled: true,
+ // Enable color scheme simulation.
+ colorSchemeSimulation: true,
+ // Enable custom formatters
+ customFormatters: true,
+ // Set a custom user agent
+ customUserAgent: true,
+ // Enable JavaScript
+ javascriptEnabled: true,
+ // Force a custom device pixel ratio (used in RDM). Set to null to restore origin ratio.
+ overrideDPPX: true,
+ // Enable print simulation mode.
+ printSimulationEnabled: true,
+ // Override navigator.maxTouchPoints (used in RDM and doesn't apply if RDM isn't enabled)
+ rdmPaneMaxTouchPoints: true,
+ // Page orientation (used in RDM and doesn't apply if RDM isn't enabled)
+ rdmPaneOrientation: true,
+ // Enable allocation tracking, if set, contains an object defining the tracking configurations
+ recordAllocations: true,
+ // Reload the page when the touch simulation state changes (only works alongside touchEventsOverride)
+ reloadOnTouchSimulationToggle: true,
+ // Restore focus in the page after closing DevTools.
+ restoreFocus: true,
+ // Enable service worker testing over HTTP (instead of HTTPS only).
+ serviceWorkersTestingEnabled: true,
+ // Set the current tab offline
+ setTabOffline: true,
+ // Enable touch events simulation
+ touchEventsOverride: true,
+ // Used to configure and start/stop the JavaScript tracer
+ tracerOptions: true,
+ // Use simplified highlighters when prefers-reduced-motion is enabled.
+ useSimpleHighlightersForReducedMotion: true,
+};
+/* eslint-disable sort-keys */
+
+/**
+ * This actor manages the configuration flags which apply to DevTools targets.
+ *
+ * Configuration flags should be applied to all concerned targets when the
+ * configuration is updated, and new targets should also be able to read the
+ * flags when they are created. The flags will be forwarded to the WatcherActor
+ * and stored as TARGET_CONFIGURATION data entries.
+ * Some flags will be set directly set from this actor, in the parent process
+ * (see _updateParentProcessConfiguration), and others will be set from the target actor,
+ * in the content process.
+ *
+ * @constructor
+ *
+ */
+class TargetConfigurationActor extends Actor {
+ constructor(watcherActor) {
+ super(watcherActor.conn, targetConfigurationSpec);
+ this.watcherActor = watcherActor;
+
+ this._onBrowsingContextAttached =
+ this._onBrowsingContextAttached.bind(this);
+ // We need to be notified of new browsing context being created so we can re-set flags
+ // we already set on the "previous" browsing context. We're using this event as it's
+ // emitted very early in the document lifecycle (i.e. before any script on the page is
+ // executed), which is not the case for "window-global-created" for example.
+ Services.obs.addObserver(
+ this._onBrowsingContextAttached,
+ "browsing-context-attached"
+ );
+
+ // When we perform a bfcache navigation, the current browsing context gets
+ // replaced with a browsing which was previously stored in bfcache and we
+ // should update our reference accordingly.
+ this._onBfCacheNavigation = this._onBfCacheNavigation.bind(this);
+ this.watcherActor.on(
+ "bf-cache-navigation-pageshow",
+ this._onBfCacheNavigation
+ );
+
+ this._browsingContext = this.watcherActor.browserElement?.browsingContext;
+ }
+
+ form() {
+ return {
+ actor: this.actorID,
+ configuration: this._getConfiguration(),
+ traits: { supportedOptions: SUPPORTED_OPTIONS },
+ };
+ }
+
+ /**
+ * Returns whether or not this actor should handle the flag that should be set on the
+ * BrowsingContext in the parent process.
+ *
+ * @returns {Boolean}
+ */
+ _shouldHandleConfigurationInParentProcess() {
+ // Only handle parent process configuration if the watcherActor is tied to a
+ // browser element.
+ // For now, the Browser Toolbox and Web Extension are having a unique target
+ // which applies the configuration by itself on new documents.
+ return this.watcherActor.sessionContext.type == "browser-element";
+ }
+
+ /**
+ * Event handler for attached browsing context. This will be called when
+ * a new browsing context is created that we might want to handle
+ * (e.g. when navigating to a page with Cross-Origin-Opener-Policy header)
+ */
+ _onBrowsingContextAttached(browsingContext) {
+ if (!this._shouldHandleConfigurationInParentProcess()) {
+ return;
+ }
+
+ // We only want to set flags on top-level browsing context. The platform
+ // will take care of propagating it to the entire browsing contexts tree.
+ if (browsingContext.parent) {
+ return;
+ }
+
+ // Only process BrowsingContexts which are related to the debugged scope.
+ // As this callback fires very early, the BrowsingContext may not have
+ // any WindowGlobal yet and so we ignore all checks dones against the WindowGlobal
+ // if there is none. Meaning we might accept more BrowsingContext than expected.
+ if (
+ !isBrowsingContextPartOfContext(
+ browsingContext,
+ this.watcherActor.sessionContext,
+ { acceptNoWindowGlobal: true, forceAcceptTopLevelTarget: true }
+ )
+ ) {
+ return;
+ }
+
+ const rdmEnabledInPreviousBrowsingContext = this._browsingContext.inRDMPane;
+
+ // Before replacing the target browsing context, restore the configuration
+ // on the previous one if they share the same browser.
+ if (
+ this._browsingContext &&
+ this._browsingContext.browserId === browsingContext.browserId &&
+ !this._browsingContext.isDiscarded
+ ) {
+ // For now this should always be true as long as we already had a browsing
+ // context set, but the same logic should be used when supporting EFT on
+ // toolboxes with several top level browsing contexts: when a new browsing
+ // context attaches, only reset the browsing context with the same browserId
+ this._restoreParentProcessConfiguration();
+ }
+
+ // We need to store the browsing context as this.watcherActor.browserElement.browsingContext
+ // can still refer to the previous browsing context at this point.
+ this._browsingContext = browsingContext;
+
+ // If `inRDMPane` was set in the previous browsing context, set it again on the new one,
+ // otherwise some RDM-related configuration won't be applied (e.g. orientation).
+ if (rdmEnabledInPreviousBrowsingContext) {
+ this._browsingContext.inRDMPane = true;
+ }
+ this._updateParentProcessConfiguration(this._getConfiguration());
+ }
+
+ _onBfCacheNavigation({ windowGlobal } = {}) {
+ if (windowGlobal) {
+ this._onBrowsingContextAttached(windowGlobal.browsingContext);
+ }
+ }
+
+ _getConfiguration() {
+ const targetConfigurationData =
+ this.watcherActor.getSessionDataForType(TARGET_CONFIGURATION);
+ if (!targetConfigurationData) {
+ return {};
+ }
+
+ const cfgMap = {};
+ for (const { key, value } of targetConfigurationData) {
+ cfgMap[key] = value;
+ }
+ return cfgMap;
+ }
+
+ /**
+ *
+ * @param {Object} configuration
+ * @returns Promise<Object> Applied configuration object
+ */
+ async updateConfiguration(configuration) {
+ const cfgArray = Object.keys(configuration)
+ .filter(key => {
+ if (!SUPPORTED_OPTIONS[key]) {
+ console.warn(`Unsupported option for TargetConfiguration: ${key}`);
+ return false;
+ }
+ return true;
+ })
+ .map(key => ({ key, value: configuration[key] }));
+
+ this._updateParentProcessConfiguration(configuration);
+ await this.watcherActor.addOrSetDataEntry(
+ TARGET_CONFIGURATION,
+ cfgArray,
+ "add"
+ );
+ return this._getConfiguration();
+ }
+
+ /**
+ *
+ * @param {Object} configuration: See `updateConfiguration`
+ */
+ _updateParentProcessConfiguration(configuration) {
+ if (!this._shouldHandleConfigurationInParentProcess()) {
+ return;
+ }
+
+ let shouldReload = false;
+ for (const [key, value] of Object.entries(configuration)) {
+ switch (key) {
+ case "colorSchemeSimulation":
+ this._setColorSchemeSimulation(value);
+ break;
+ case "customUserAgent":
+ this._setCustomUserAgent(value);
+ break;
+ case "javascriptEnabled":
+ if (value !== undefined) {
+ // This flag requires a reload in order to take full effect,
+ // so reload if it has changed.
+ if (value != this.isJavascriptEnabled()) {
+ shouldReload = true;
+ }
+ this._setJavascriptEnabled(value);
+ }
+ break;
+ case "overrideDPPX":
+ this._setDPPXOverride(value);
+ break;
+ case "printSimulationEnabled":
+ this._setPrintSimulationEnabled(value);
+ break;
+ case "rdmPaneMaxTouchPoints":
+ this._setRDMPaneMaxTouchPoints(value);
+ break;
+ case "rdmPaneOrientation":
+ this._setRDMPaneOrientation(value);
+ break;
+ case "serviceWorkersTestingEnabled":
+ this._setServiceWorkersTestingEnabled(value);
+ break;
+ case "touchEventsOverride":
+ this._setTouchEventsOverride(value);
+ break;
+ case "cacheDisabled":
+ this._setCacheDisabled(value);
+ break;
+ case "setTabOffline":
+ this._setTabOffline(value);
+ break;
+ }
+ }
+
+ if (shouldReload) {
+ this._browsingContext.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE);
+ }
+ }
+
+ _restoreParentProcessConfiguration() {
+ if (!this._shouldHandleConfigurationInParentProcess()) {
+ return;
+ }
+
+ this._setServiceWorkersTestingEnabled(false);
+ this._setPrintSimulationEnabled(false);
+ this._setCacheDisabled(false);
+ this._setTabOffline(false);
+
+ // Restore the color scheme simulation only if it was explicitly updated
+ // by this actor. This will avoid side effects caused when destroying additional
+ // targets (e.g. RDM target, WebExtension target, …).
+ // TODO: We may want to review other configuration values to see if we should use
+ // the same pattern (Bug 1701553).
+ if (this._resetColorSchemeSimulationOnDestroy) {
+ this._setColorSchemeSimulation(null);
+ }
+
+ // Restore the user agent only if it was explicitly updated by this specific actor.
+ if (this._initialUserAgent !== undefined) {
+ this._setCustomUserAgent(this._initialUserAgent);
+ }
+
+ // Restore the origin device pixel ratio only if it was explicitly updated by this
+ // specific actor.
+ if (this._initialDPPXOverride !== undefined) {
+ this._setDPPXOverride(this._initialDPPXOverride);
+ }
+
+ if (this._initialJavascriptEnabled !== undefined) {
+ this._setJavascriptEnabled(this._initialJavascriptEnabled);
+ }
+
+ if (this._initialTouchEventsOverride !== undefined) {
+ this._setTouchEventsOverride(this._initialTouchEventsOverride);
+ }
+ }
+
+ /**
+ * Disable or enable the service workers testing features.
+ */
+ _setServiceWorkersTestingEnabled(enabled) {
+ if (this._browsingContext.serviceWorkersTestingEnabled != enabled) {
+ this._browsingContext.serviceWorkersTestingEnabled = enabled;
+ }
+ }
+
+ /**
+ * Disable or enable the print simulation.
+ */
+ _setPrintSimulationEnabled(enabled) {
+ const value = enabled ? "print" : "";
+ if (this._browsingContext.mediumOverride != value) {
+ this._browsingContext.mediumOverride = value;
+ }
+ }
+
+ /**
+ * Disable or enable the color-scheme simulation.
+ */
+ _setColorSchemeSimulation(override) {
+ const value = override || "none";
+ if (this._browsingContext.prefersColorSchemeOverride != value) {
+ this._browsingContext.prefersColorSchemeOverride = value;
+ this._resetColorSchemeSimulationOnDestroy = true;
+ }
+ }
+
+ /**
+ * Set a custom user agent on the page
+ *
+ * @param {String} userAgent: The user agent to set on the page. If null, will reset the
+ * user agent to its original value.
+ * @returns {Boolean} Whether the user agent was changed or not.
+ */
+ _setCustomUserAgent(userAgent = "") {
+ if (this._browsingContext.customUserAgent === userAgent) {
+ return;
+ }
+
+ if (this._initialUserAgent === undefined) {
+ this._initialUserAgent = this._browsingContext.customUserAgent;
+ }
+
+ this._browsingContext.customUserAgent = userAgent;
+ }
+
+ isJavascriptEnabled() {
+ return this._browsingContext.allowJavascript;
+ }
+
+ _setJavascriptEnabled(allow) {
+ if (this._initialJavascriptEnabled === undefined) {
+ this._initialJavascriptEnabled = this._browsingContext.allowJavascript;
+ }
+ if (allow !== undefined) {
+ this._browsingContext.allowJavascript = allow;
+ }
+ }
+
+ /* DPPX override */
+ _setDPPXOverride(dppx) {
+ if (this._browsingContext.overrideDPPX === dppx) {
+ return;
+ }
+
+ if (!dppx && this._initialDPPXOverride) {
+ dppx = this._initialDPPXOverride;
+ } else if (dppx !== undefined && this._initialDPPXOverride === undefined) {
+ this._initialDPPXOverride = this._browsingContext.overrideDPPX;
+ }
+
+ if (dppx !== undefined) {
+ this._browsingContext.overrideDPPX = dppx;
+ }
+ }
+
+ /**
+ * Set the touchEventsOverride on the browsing context.
+ *
+ * @param {String} flag: See BrowsingContext.webidl `TouchEventsOverride` enum for values.
+ */
+ _setTouchEventsOverride(flag) {
+ if (this._browsingContext.touchEventsOverride === flag) {
+ return;
+ }
+
+ if (!flag && this._initialTouchEventsOverride) {
+ flag = this._initialTouchEventsOverride;
+ } else if (
+ flag !== undefined &&
+ this._initialTouchEventsOverride === undefined
+ ) {
+ this._initialTouchEventsOverride =
+ this._browsingContext.touchEventsOverride;
+ }
+
+ if (flag !== undefined) {
+ this._browsingContext.touchEventsOverride = flag;
+ }
+ }
+
+ /**
+ * Overrides navigator.maxTouchPoints.
+ * Note that we don't need to reset the original value when the actor is destroyed,
+ * as it's directly handled by the platform when RDM is closed.
+ *
+ * @param {Integer} maxTouchPoints
+ */
+ _setRDMPaneMaxTouchPoints(maxTouchPoints) {
+ this._browsingContext.setRDMPaneMaxTouchPoints(maxTouchPoints);
+ }
+
+ /**
+ * Set an orientation and an angle on the browsing context. This will be applied only
+ * if 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.
+ */
+ _setRDMPaneOrientation({ type, angle }) {
+ this._browsingContext.setRDMPaneOrientation(type, angle);
+ }
+
+ /**
+ * Disable or enable the cache via the browsing context.
+ *
+ * @param {Boolean} disabled: The state the cache should be changed to
+ */
+ _setCacheDisabled(disabled) {
+ const value = disabled
+ ? Ci.nsIRequest.LOAD_BYPASS_CACHE
+ : Ci.nsIRequest.LOAD_NORMAL;
+ if (this._browsingContext.defaultLoadFlags != value) {
+ this._browsingContext.defaultLoadFlags = value;
+ }
+ }
+
+ /**
+ * Set the browsing context to offline.
+ *
+ * @param {Boolean} offline: Whether the network throttling is set to offline
+ */
+ _setTabOffline(offline) {
+ if (!this._browsingContext.isDiscarded) {
+ this._browsingContext.forceOffline = offline;
+ }
+ }
+
+ destroy() {
+ Services.obs.removeObserver(
+ this._onBrowsingContextAttached,
+ "browsing-context-attached"
+ );
+ this.watcherActor.off(
+ "bf-cache-navigation-pageshow",
+ this._onBfCacheNavigation
+ );
+ this._restoreParentProcessConfiguration();
+ super.destroy();
+ }
+}
+
+exports.TargetConfigurationActor = TargetConfigurationActor;
diff --git a/devtools/server/actors/targets/base-target-actor.js b/devtools/server/actors/targets/base-target-actor.js
new file mode 100644
index 0000000000..f3fc2a89e7
--- /dev/null
+++ b/devtools/server/actors/targets/base-target-actor.js
@@ -0,0 +1,214 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ TYPES,
+ getResourceWatcher,
+} = require("resource://devtools/server/actors/resources/index.js");
+const Targets = require("devtools/server/actors/targets/index");
+
+loader.lazyRequireGetter(
+ this,
+ "SessionDataProcessors",
+ "resource://devtools/server/actors/targets/session-data-processors/index.js",
+ true
+);
+
+class BaseTargetActor extends Actor {
+ constructor(conn, targetType, spec) {
+ super(conn, spec);
+
+ /**
+ * Type of target, a string of Targets.TYPES.
+ * @return {string}
+ */
+ this.targetType = targetType;
+ }
+
+ /**
+ * Process a new data entry, which can be watched resources, breakpoints, ...
+ *
+ * @param string type
+ * The type of data to be added
+ * @param Array<Object> entries
+ * The values to be added to this type of data
+ * @param Boolean isDocumentCreation
+ * Set to true if this function is called just after a new document (and its
+ * associated target) is created.
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+ async addOrSetSessionDataEntry(
+ type,
+ entries,
+ isDocumentCreation = false,
+ updateType
+ ) {
+ const processor = SessionDataProcessors[type];
+ if (processor) {
+ await processor.addOrSetSessionDataEntry(
+ this,
+ entries,
+ isDocumentCreation,
+ updateType
+ );
+ }
+ }
+
+ /**
+ * Remove data entries that have been previously added via addOrSetSessionDataEntry
+ *
+ * See addOrSetSessionDataEntry for argument description.
+ */
+ removeSessionDataEntry(type, entries) {
+ const processor = SessionDataProcessors[type];
+ if (processor) {
+ processor.removeSessionDataEntry(this, entries);
+ }
+ }
+
+ /**
+ * Called by Resource Watchers, when new resources are available, updated or destroyed.
+ *
+ * @param String updateType
+ * Can be "available", "updated" or "destroyed"
+ * @param Array<json> resources
+ * List of all resource's form. A resource is a JSON object piped over to the client.
+ * It can contain actor IDs, actor forms, to be manually marshalled by the client.
+ */
+ notifyResources(updateType, resources) {
+ if (resources.length === 0 || this.isDestroyed()) {
+ // Don't try to emit if the resources array is empty or the actor was
+ // destroyed.
+ return;
+ }
+
+ if (this.devtoolsSpawnedBrowsingContextForWebExtension) {
+ this.overrideResourceBrowsingContextForWebExtension(resources);
+ }
+
+ this.emit(`resource-${updateType}-form`, resources);
+ }
+
+ /**
+ * For WebExtension, we have to hack all resource's browsingContextID
+ * in order to ensure emitting them with the fixed, original browsingContextID
+ * related to the fallback document created by devtools which always exists.
+ * The target's form will always be relating to that BrowsingContext IDs (browsing context ID and inner window id).
+ * Even if the target switches internally to another document via WindowGlobalTargetActor._setWindow.
+ *
+ * @param {Array<Objects>} List of resources
+ */
+ overrideResourceBrowsingContextForWebExtension(resources) {
+ const browsingContextID =
+ this.devtoolsSpawnedBrowsingContextForWebExtension.id;
+ resources.forEach(
+ resource => (resource.browsingContextID = browsingContextID)
+ );
+ }
+
+ // List of actor prefixes (string) which have already been instantiated via getTargetScopedActor method.
+ #instantiatedTargetScopedActors = new Set();
+
+ /**
+ * Try to return any target scoped actor instance, if it exists.
+ * They are lazily instantiated and so will only be available
+ * if the client called at least one of their method.
+ *
+ * @param {String} prefix
+ * Prefix for the actor we would like to retrieve.
+ * Defined in devtools/server/actors/utils/actor-registry.js
+ */
+ getTargetScopedActor(prefix) {
+ if (this.isDestroyed()) {
+ return null;
+ }
+ const form = this.form();
+ this.#instantiatedTargetScopedActors.add(prefix);
+ return this.conn._getOrCreateActor(form[prefix + "Actor"]);
+ }
+
+ /**
+ * Returns true, if the related target scoped actor has already been queried
+ * and instantiated via `getTargetScopedActor` method.
+ *
+ * @param {String} prefix
+ * See getTargetScopedActor definition
+ * @return Boolean
+ * True, if the actor has already been instantiated.
+ */
+ hasTargetScopedActor(prefix) {
+ return this.#instantiatedTargetScopedActors.has(prefix);
+ }
+
+ /**
+ * Apply target-specific options.
+ *
+ * This will be called by the watcher when the DevTools target-configuration
+ * is updated, or when a target is created via JSWindowActors.
+ *
+ * @param {JSON} options
+ * Configuration object provided by the client.
+ * See target-configuration actor.
+ * @param {Boolean} calledFromDocumentCreate
+ * True, when this is called with initial configuration when the related target
+ * actor is instantiated.
+ */
+ updateTargetConfiguration(options = {}, calledFromDocumentCreation = false) {
+ // If there is some tracer options, we should start tracing, otherwise we should stop (if we were)
+ if (options.tracerOptions) {
+ // Ignore the SessionData update if the user requested to start the tracer on next page load and:
+ // - we apply it to an already loaded WindowGlobal,
+ // - the target isn't the top level one.
+ if (
+ options.tracerOptions.traceOnNextLoad &&
+ (!calledFromDocumentCreation || !this.isTopLevelTarget)
+ ) {
+ if (this.isTopLevelTarget) {
+ const consoleMessageWatcher = getResourceWatcher(
+ this,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (consoleMessageWatcher) {
+ consoleMessageWatcher.emitMessages([
+ {
+ arguments: [
+ "Waiting for next navigation or page reload before starting tracing",
+ ],
+ styles: [],
+ level: "jstracer",
+ chromeContext: false,
+ timeStamp: ChromeUtils.dateNow(),
+ },
+ ]);
+ }
+ }
+ return;
+ }
+ // Bug 1874204: For now, in the browser toolbox, only frame and workers are traced.
+ // Content process targets are ignored as they would also include each document/frame target.
+ // This would require some work to ignore FRAME targets from here, only in case of browser toolbox,
+ // and also handle all content process documents for DOM Event logging.
+ //
+ // Bug 1874219: Also ignore extensions for now as they are all running in the same process,
+ // whereas we can only spawn one tracer per thread.
+ if (
+ this.targetType == Targets.TYPES.PROCESS ||
+ this.url?.startsWith("moz-extension://")
+ ) {
+ return;
+ }
+ const tracerActor = this.getTargetScopedActor("tracer");
+ tracerActor.startTracing(options.tracerOptions);
+ } else if (this.hasTargetScopedActor("tracer")) {
+ const tracerActor = this.getTargetScopedActor("tracer");
+ tracerActor.stopTracing();
+ }
+ }
+}
+exports.BaseTargetActor = BaseTargetActor;
diff --git a/devtools/server/actors/targets/content-process.js b/devtools/server/actors/targets/content-process.js
new file mode 100644
index 0000000000..56b1934ef1
--- /dev/null
+++ b/devtools/server/actors/targets/content-process.js
@@ -0,0 +1,265 @@
+/* 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";
+
+/*
+ * Target actor for all resources in a content process of Firefox (chrome sandboxes, frame
+ * scripts, documents, etc.)
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ */
+
+const { ThreadActor } = require("resource://devtools/server/actors/thread.js");
+const {
+ WebConsoleActor,
+} = require("resource://devtools/server/actors/webconsole.js");
+const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js");
+const { Pool } = require("resource://devtools/shared/protocol.js");
+const { assert } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ SourcesManager,
+} = require("resource://devtools/server/actors/utils/sources-manager.js");
+const {
+ contentProcessTargetSpec,
+} = require("resource://devtools/shared/specs/targets/content-process.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+const {
+ BaseTargetActor,
+} = require("resource://devtools/server/actors/targets/base-target-actor.js");
+const { TargetActorRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs",
+ {
+ loadInDevToolsLoader: false,
+ }
+);
+
+loader.lazyRequireGetter(
+ this,
+ "WorkerDescriptorActorList",
+ "resource://devtools/server/actors/worker/worker-descriptor-actor-list.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "MemoryActor",
+ "resource://devtools/server/actors/memory.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "TracerActor",
+ "resource://devtools/server/actors/tracer.js",
+ true
+);
+
+class ContentProcessTargetActor extends BaseTargetActor {
+ constructor(conn, { isXpcShellTarget = false, sessionContext } = {}) {
+ super(conn, Targets.TYPES.PROCESS, contentProcessTargetSpec);
+
+ this.threadActor = null;
+ this.isXpcShellTarget = isXpcShellTarget;
+ this.sessionContext = sessionContext;
+
+ // Use a see-everything debugger
+ this.makeDebugger = makeDebugger.bind(null, {
+ findDebuggees: dbg =>
+ dbg.findAllGlobals().map(g => g.unsafeDereference()),
+ shouldAddNewGlobalAsDebuggee: global => true,
+ });
+
+ const sandboxPrototype = {
+ get tabs() {
+ return Array.from(
+ Services.ww.getWindowEnumerator(),
+ win => win.docShell.messageManager
+ );
+ },
+ };
+
+ // Scope into which the webconsole executes:
+ // A sandbox with chrome privileges with a `tabs` getter.
+ const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
+ Ci.nsIPrincipal
+ );
+ const sandbox = Cu.Sandbox(systemPrincipal, {
+ sandboxPrototype,
+ wantGlobalProperties: ["ChromeUtils"],
+ });
+ this._consoleScope = sandbox;
+
+ this._workerList = null;
+ this._workerDescriptorActorPool = null;
+ this._onWorkerListChanged = this._onWorkerListChanged.bind(this);
+
+ // Try to destroy the Content Process Target when the content process shuts down.
+ // The parent process can't communicate during shutdown as the communication channel
+ // is already down (message manager or JS Window Actor API).
+ // So that we have to observe to some event fired from this process.
+ // While such cleanup doesn't sound ultimately necessary (the process will be completely destroyed)
+ // mochitests are asserting that there is no leaks during process shutdown.
+ // Do not override destroy as Protocol.js may override it when calling destroy,
+ // and we won't be able to call removeObserver correctly.
+ this.destroyObserver = this.destroy.bind(this);
+ Services.obs.addObserver(this.destroyObserver, "xpcom-shutdown");
+ if (this.isXpcShellTarget) {
+ TargetActorRegistry.registerXpcShellTargetActor(this);
+ }
+ }
+
+ get isRootActor() {
+ return true;
+ }
+
+ get url() {
+ return undefined;
+ }
+
+ get window() {
+ return this._consoleScope;
+ }
+
+ get sourcesManager() {
+ if (!this._sourcesManager) {
+ assert(
+ this.threadActor,
+ "threadActor should exist when creating SourcesManager."
+ );
+ this._sourcesManager = new SourcesManager(this.threadActor);
+ }
+ return this._sourcesManager;
+ }
+
+ /*
+ * Return a Debugger instance or create one if there is none yet
+ */
+ get dbg() {
+ if (!this._dbg) {
+ this._dbg = this.makeDebugger();
+ }
+ return this._dbg;
+ }
+
+ form() {
+ if (!this._consoleActor) {
+ this._consoleActor = new WebConsoleActor(this.conn, this);
+ this.manage(this._consoleActor);
+ }
+
+ if (!this.threadActor) {
+ this.threadActor = new ThreadActor(this, null);
+ this.manage(this.threadActor);
+ }
+ if (!this.memoryActor) {
+ this.memoryActor = new MemoryActor(this.conn, this);
+ this.manage(this.memoryActor);
+ }
+ if (!this.tracerActor) {
+ this.tracerActor = new TracerActor(this.conn, this);
+ this.manage(this.tracerActor);
+ }
+
+ return {
+ actor: this.actorID,
+ isXpcShellTarget: this.isXpcShellTarget,
+ processID: Services.appinfo.processID,
+ remoteType: Services.appinfo.remoteType,
+
+ consoleActor: this._consoleActor.actorID,
+ memoryActor: this.memoryActor.actorID,
+ threadActor: this.threadActor.actorID,
+ tracerActor: this.tracerActor.actorID,
+
+ traits: {
+ networkMonitor: false,
+ // See trait description in browsing-context.js
+ supportsTopLevelTargetFlag: false,
+ },
+ };
+ }
+
+ ensureWorkerList() {
+ if (!this._workerList) {
+ this._workerList = new WorkerDescriptorActorList(this.conn, {});
+ }
+ return this._workerList;
+ }
+
+ listWorkers() {
+ return this.ensureWorkerList()
+ .getList()
+ .then(actors => {
+ const pool = new Pool(this.conn, "workers");
+ for (const actor of actors) {
+ pool.manage(actor);
+ }
+
+ // Do not destroy the pool before transfering ownership to the newly created
+ // pool, so that we do not accidentally destroy actors that are still in use.
+ if (this._workerDescriptorActorPool) {
+ this._workerDescriptorActorPool.destroy();
+ }
+
+ this._workerDescriptorActorPool = pool;
+ this._workerList.onListChanged = this._onWorkerListChanged;
+
+ return { workers: actors };
+ });
+ }
+
+ _onWorkerListChanged() {
+ this.conn.send({ from: this.actorID, type: "workerListChanged" });
+ this._workerList.onListChanged = null;
+ }
+
+ pauseMatchingServiceWorkers(request) {
+ this.ensureWorkerList().workerPauser.setPauseServiceWorkers(request.origin);
+ }
+
+ destroy() {
+ // Avoid reentrancy. We will destroy the Transport when emitting "destroyed",
+ // which will force destroying all actors.
+ if (this.destroying) {
+ return;
+ }
+ this.destroying = true;
+
+ // Unregistering watchers first is important
+ // otherwise you might have leaks reported when running browser_browser_toolbox_netmonitor.js in debug builds
+ Resources.unwatchAllResources(this);
+
+ this.emit("destroyed");
+
+ super.destroy();
+
+ if (this.threadActor) {
+ this.threadActor = null;
+ }
+
+ // Tell the live lists we aren't watching any more.
+ if (this._workerList) {
+ this._workerList.destroy();
+ this._workerList = null;
+ }
+
+ if (this._sourcesManager) {
+ this._sourcesManager.destroy();
+ this._sourcesManager = null;
+ }
+
+ if (this._dbg) {
+ this._dbg.disable();
+ this._dbg = null;
+ }
+
+ Services.obs.removeObserver(this.destroyObserver, "xpcom-shutdown");
+
+ if (this.isXpcShellTarget) {
+ TargetActorRegistry.unregisterXpcShellTargetActor(this);
+ }
+ }
+}
+
+exports.ContentProcessTargetActor = ContentProcessTargetActor;
diff --git a/devtools/server/actors/targets/index.js b/devtools/server/actors/targets/index.js
new file mode 100644
index 0000000000..61501d37e8
--- /dev/null
+++ b/devtools/server/actors/targets/index.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";
+
+const TYPES = {
+ FRAME: "frame",
+ PROCESS: "process",
+ WORKER: "worker",
+ SERVICE_WORKER: "service_worker",
+ SHARED_WORKER: "shared_worker",
+};
+exports.TYPES = TYPES;
diff --git a/devtools/server/actors/targets/moz.build b/devtools/server/actors/targets/moz.build
new file mode 100644
index 0000000000..f4d44ae669
--- /dev/null
+++ b/devtools/server/actors/targets/moz.build
@@ -0,0 +1,20 @@
+# -*- 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 += [
+ "session-data-processors",
+]
+
+DevToolsModules(
+ "base-target-actor.js",
+ "content-process.js",
+ "index.js",
+ "parent-process.js",
+ "target-actor-registry.sys.mjs",
+ "webextension.js",
+ "window-global.js",
+ "worker.js",
+)
diff --git a/devtools/server/actors/targets/parent-process.js b/devtools/server/actors/targets/parent-process.js
new file mode 100644
index 0000000000..4b7da5e9a4
--- /dev/null
+++ b/devtools/server/actors/targets/parent-process.js
@@ -0,0 +1,167 @@
+/* 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";
+
+/*
+ * Target actor for the entire parent process.
+ *
+ * This actor extends WindowGlobalTargetActor.
+ * This actor is extended by WebExtensionTargetActor.
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ */
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const {
+ getChildDocShells,
+ WindowGlobalTargetActor,
+} = require("resource://devtools/server/actors/targets/window-global.js");
+const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js");
+
+const {
+ parentProcessTargetSpec,
+} = require("resource://devtools/shared/specs/targets/parent-process.js");
+
+class ParentProcessTargetActor extends WindowGlobalTargetActor {
+ /**
+ * Creates a target actor for debugging all the chrome content in the parent process.
+ * Most of the implementation is inherited from WindowGlobalTargetActor.
+ * ParentProcessTargetActor is a child of RootActor, it can be instantiated via
+ * RootActor.getProcess request. ParentProcessTargetActor exposes all target-scoped actors
+ * via its form() request, like WindowGlobalTargetActor.
+ *
+ * @param conn DevToolsServerConnection
+ * The connection to the client.
+ * @param {Object} options
+ * - isTopLevelTarget: {Boolean} flag to indicate if this is the top
+ * level target of the DevTools session
+ * - sessionContext Object
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * - customSpec Object
+ * WebExtensionTargetActor inherits from ParentProcessTargetActor
+ * and has to use its own protocol.js specification object.
+ */
+ constructor(
+ conn,
+ { isTopLevelTarget, sessionContext, customSpec = parentProcessTargetSpec }
+ ) {
+ super(conn, {
+ isTopLevelTarget,
+ sessionContext,
+ customSpec,
+ });
+
+ // This creates a Debugger instance for chrome debugging all globals.
+ this.makeDebugger = makeDebugger.bind(null, {
+ findDebuggees: dbg =>
+ dbg.findAllGlobals().map(g => g.unsafeDereference()),
+ shouldAddNewGlobalAsDebuggee: () => true,
+ });
+
+ // Ensure catching the creation of any new content docshell
+ this.watchNewDocShells = true;
+
+ this.isRootActor = true;
+
+ // Listen for any new/destroyed chrome docshell
+ Services.obs.addObserver(this, "chrome-webnavigation-create");
+ Services.obs.addObserver(this, "chrome-webnavigation-destroy");
+
+ // If we are the parent process target actor and not a subclass
+ // (i.e. if we aren't the webext target actor)
+ // set the parent process docshell:
+ if (customSpec == parentProcessTargetSpec) {
+ this.setDocShell(this._getInitialDocShell());
+ }
+ }
+
+ // Overload setDocShell in order to observe all the docshells.
+ // WindowGlobalTargetActor only observes the top level one,
+ // but we also need to observe all of them for WebExtensionTargetActor subclass.
+ setDocShell(initialDocShell) {
+ super.setDocShell(initialDocShell);
+
+ // Iterate over all top-level windows.
+ for (const { docShell } of Services.ww.getWindowEnumerator()) {
+ if (docShell == this.docShell) {
+ continue;
+ }
+ this._progressListener.watch(docShell);
+ }
+ }
+
+ _getInitialDocShell() {
+ // Defines the default docshell selected for the target actor
+ let window = Services.wm.getMostRecentWindow(
+ DevToolsServer.chromeWindowType
+ );
+
+ // Default to any available top level window if there is no expected window
+ // eg when running ./mach run --chrome chrome://browser/content/aboutTabCrashed.xhtml --jsdebugger
+ if (!window) {
+ window = Services.wm.getMostRecentWindow(null);
+ }
+
+ // We really want _some_ window at least, so fallback to the hidden window if
+ // there's nothing else (such as during early startup).
+ if (!window) {
+ window = Services.appShell.hiddenDOMWindow;
+ }
+ return window.docShell;
+ }
+
+ /**
+ * Getter for the list of all docshells in this targetActor
+ * @return {Array}
+ */
+ get docShells() {
+ // Iterate over all top-level windows and all their docshells.
+ let docShells = [];
+ for (const { docShell } of Services.ww.getWindowEnumerator()) {
+ docShells = docShells.concat(getChildDocShells(docShell));
+ }
+
+ return docShells;
+ }
+
+ observe(subject, topic, data) {
+ super.observe(subject, topic, data);
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ subject.QueryInterface(Ci.nsIDocShell);
+
+ if (topic == "chrome-webnavigation-create") {
+ this._onDocShellCreated(subject);
+ } else if (topic == "chrome-webnavigation-destroy") {
+ this._onDocShellDestroy(subject);
+ }
+ }
+
+ _detach() {
+ if (this.isDestroyed()) {
+ return false;
+ }
+
+ Services.obs.removeObserver(this, "chrome-webnavigation-create");
+ Services.obs.removeObserver(this, "chrome-webnavigation-destroy");
+
+ // Iterate over all top-level windows.
+ for (const { docShell } of Services.ww.getWindowEnumerator()) {
+ if (docShell == this.docShell) {
+ continue;
+ }
+ this._progressListener.unwatch(docShell);
+ }
+
+ return super._detach();
+ }
+}
+
+exports.ParentProcessTargetActor = ParentProcessTargetActor;
diff --git a/devtools/server/actors/targets/session-data-processors/blackboxing.js b/devtools/server/actors/targets/session-data-processors/blackboxing.js
new file mode 100644
index 0000000000..70f4397a72
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/blackboxing.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";
+
+module.exports = {
+ async addOrSetSessionDataEntry(
+ targetActor,
+ entries,
+ isDocumentCreation,
+ updateType
+ ) {
+ const { sourcesManager } = targetActor;
+ if (updateType == "set") {
+ sourcesManager.clearAllBlackBoxing();
+ }
+ for (const { url, range } of entries) {
+ sourcesManager.blackBox(url, range);
+ }
+ },
+
+ removeSessionDataEntry(targetActor, entries, isDocumentCreation) {
+ for (const { url, range } of entries) {
+ targetActor.sourcesManager.unblackBox(url, range);
+ }
+ },
+};
diff --git a/devtools/server/actors/targets/session-data-processors/breakpoints.js b/devtools/server/actors/targets/session-data-processors/breakpoints.js
new file mode 100644
index 0000000000..ff7cb7ec0a
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/breakpoints.js
@@ -0,0 +1,45 @@
+/* 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 {
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+module.exports = {
+ async addOrSetSessionDataEntry(
+ targetActor,
+ entries,
+ isDocumentCreation,
+ updateType
+ ) {
+ const { threadActor } = targetActor;
+ if (updateType == "set") {
+ threadActor.removeAllBreakpoints();
+ }
+ const isTargetCreation = threadActor.state == THREAD_STATES.DETACHED;
+ if (isTargetCreation && !targetActor.targetType.endsWith("worker")) {
+ // If addOrSetSessionDataEntry is called during target creation, attach the
+ // thread actor automatically and pass the initial breakpoints.
+ // However, do not attach the thread actor for Workers. They use a codepath
+ // which releases the worker on `attach`. For them, the client will call `attach`. (bug 1691986)
+ await threadActor.attach({ breakpoints: entries });
+ } else {
+ // If addOrSetSessionDataEntry is called for an existing target, set the new
+ // breakpoints on the already running thread actor.
+ await Promise.all(
+ entries.map(({ location, options }) =>
+ threadActor.setBreakpoint(location, options)
+ )
+ );
+ }
+ },
+
+ removeSessionDataEntry(targetActor, entries, isDocumentCreation) {
+ for (const { location } of entries) {
+ targetActor.threadActor.removeBreakpoint(location);
+ }
+ },
+};
diff --git a/devtools/server/actors/targets/session-data-processors/event-breakpoints.js b/devtools/server/actors/targets/session-data-processors/event-breakpoints.js
new file mode 100644
index 0000000000..c0a2fb7ffe
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/event-breakpoints.js
@@ -0,0 +1,36 @@
+/* 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 {
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+module.exports = {
+ async addOrSetSessionDataEntry(
+ targetActor,
+ entries,
+ isDocumentCreation,
+ updateType
+ ) {
+ const { threadActor } = targetActor;
+ // Same as comments for XHR breakpoints. See lines 117-118
+ if (
+ threadActor.state == THREAD_STATES.DETACHED &&
+ !targetActor.targetType.endsWith("worker")
+ ) {
+ threadActor.attach();
+ }
+ if (updateType == "set") {
+ threadActor.setActiveEventBreakpoints(entries);
+ } else {
+ threadActor.addEventBreakpoints(entries);
+ }
+ },
+
+ removeSessionDataEntry(targetActor, entries, isDocumentCreation) {
+ targetActor.threadActor.removeEventBreakpoints(entries);
+ },
+};
diff --git a/devtools/server/actors/targets/session-data-processors/index.js b/devtools/server/actors/targets/session-data-processors/index.js
new file mode 100644
index 0000000000..19b7d69302
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/index.js
@@ -0,0 +1,50 @@
+/* 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 {
+ SessionDataHelpers,
+} = require("resource://devtools/server/actors/watcher/SessionDataHelpers.jsm");
+const { SUPPORTED_DATA } = SessionDataHelpers;
+
+const SessionDataProcessors = {};
+
+loader.lazyRequireGetter(
+ SessionDataProcessors,
+ SUPPORTED_DATA.BLACKBOXING,
+ "resource://devtools/server/actors/targets/session-data-processors/blackboxing.js"
+);
+loader.lazyRequireGetter(
+ SessionDataProcessors,
+ SUPPORTED_DATA.BREAKPOINTS,
+ "resource://devtools/server/actors/targets/session-data-processors/breakpoints.js"
+);
+loader.lazyRequireGetter(
+ SessionDataProcessors,
+ SUPPORTED_DATA.EVENT_BREAKPOINTS,
+ "resource://devtools/server/actors/targets/session-data-processors/event-breakpoints.js"
+);
+loader.lazyRequireGetter(
+ SessionDataProcessors,
+ SUPPORTED_DATA.RESOURCES,
+ "resource://devtools/server/actors/targets/session-data-processors/resources.js"
+);
+loader.lazyRequireGetter(
+ SessionDataProcessors,
+ SUPPORTED_DATA.TARGET_CONFIGURATION,
+ "resource://devtools/server/actors/targets/session-data-processors/target-configuration.js"
+);
+loader.lazyRequireGetter(
+ SessionDataProcessors,
+ SUPPORTED_DATA.THREAD_CONFIGURATION,
+ "resource://devtools/server/actors/targets/session-data-processors/thread-configuration.js"
+);
+loader.lazyRequireGetter(
+ SessionDataProcessors,
+ SUPPORTED_DATA.XHR_BREAKPOINTS,
+ "resource://devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js"
+);
+
+exports.SessionDataProcessors = SessionDataProcessors;
diff --git a/devtools/server/actors/targets/session-data-processors/moz.build b/devtools/server/actors/targets/session-data-processors/moz.build
new file mode 100644
index 0000000000..ea924d7d79
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/moz.build
@@ -0,0 +1,16 @@
+# -*- 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(
+ "blackboxing.js",
+ "breakpoints.js",
+ "event-breakpoints.js",
+ "index.js",
+ "resources.js",
+ "target-configuration.js",
+ "thread-configuration.js",
+ "xhr-breakpoints.js",
+)
diff --git a/devtools/server/actors/targets/session-data-processors/resources.js b/devtools/server/actors/targets/session-data-processors/resources.js
new file mode 100644
index 0000000000..8f33ba8e0f
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/resources.js
@@ -0,0 +1,25 @@
+/* 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 Resources = require("resource://devtools/server/actors/resources/index.js");
+
+module.exports = {
+ async addOrSetSessionDataEntry(
+ targetActor,
+ entries,
+ isDocumentCreation,
+ updateType
+ ) {
+ if (updateType == "set") {
+ Resources.unwatchAllResources(targetActor);
+ }
+ await Resources.watchResources(targetActor, entries);
+ },
+
+ removeSessionDataEntry(targetActor, entries, isDocumentCreation) {
+ Resources.unwatchResources(targetActor, entries);
+ },
+};
diff --git a/devtools/server/actors/targets/session-data-processors/target-configuration.js b/devtools/server/actors/targets/session-data-processors/target-configuration.js
new file mode 100644
index 0000000000..f68e82d69f
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/target-configuration.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";
+
+module.exports = {
+ async addOrSetSessionDataEntry(
+ targetActor,
+ entries,
+ isDocumentCreation,
+ updateType
+ ) {
+ // Only WindowGlobalTargetActor implements updateTargetConfiguration,
+ // skip targetActor data entry update for other targets.
+ if (typeof targetActor.updateTargetConfiguration == "function") {
+ const options = {};
+ for (const { key, value } of entries) {
+ options[key] = value;
+ }
+ // Regarding `updateType`, `entries` is always a partial set of configurations.
+ // We will acknowledge the passed attribute, but if we had set some other attributes
+ // before this call, they will stay as-is.
+ // So it is as if this session data was also using "add" updateType.
+ targetActor.updateTargetConfiguration(options, isDocumentCreation);
+ }
+ },
+
+ removeSessionDataEntry(targetActor, entries, isDocumentCreation) {
+ // configuration data entries are always added/updated, never removed.
+ },
+};
diff --git a/devtools/server/actors/targets/session-data-processors/thread-configuration.js b/devtools/server/actors/targets/session-data-processors/thread-configuration.js
new file mode 100644
index 0000000000..716d2a9b21
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/thread-configuration.js
@@ -0,0 +1,41 @@
+/* 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 {
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+module.exports = {
+ async addOrSetSessionDataEntry(
+ targetActor,
+ entries,
+ isDocumentCreation,
+ updateType
+ ) {
+ const threadOptions = {};
+
+ for (const { key, value } of entries) {
+ threadOptions[key] = value;
+ }
+
+ if (
+ !targetActor.targetType.endsWith("worker") &&
+ targetActor.threadActor.state == THREAD_STATES.DETACHED
+ ) {
+ await targetActor.threadActor.attach(threadOptions);
+ } else {
+ // Regarding `updateType`, `entries` is always a partial set of configurations.
+ // We will acknowledge the passed attribute, but if we had set some other attributes
+ // before this call, they will stay as-is.
+ // So it is as if this session data was also using "add" updateType.
+ await targetActor.threadActor.reconfigure(threadOptions);
+ }
+ },
+
+ removeSessionDataEntry(targetActor, entries, isDocumentCreation) {
+ // configuration data entries are always added/updated, never removed.
+ },
+};
diff --git a/devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js b/devtools/server/actors/targets/session-data-processors/xhr-breakpoints.js
new file mode 100644
index 0000000000..7a0fd815aa
--- /dev/null
+++ b/devtools/server/actors/targets/session-data-processors/xhr-breakpoints.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 {
+ STATES: THREAD_STATES,
+} = require("resource://devtools/server/actors/thread.js");
+
+module.exports = {
+ async addOrSetSessionDataEntry(
+ targetActor,
+ entries,
+ isDocumentCreation,
+ updateType
+ ) {
+ const { threadActor } = targetActor;
+ if (updateType == "set") {
+ threadActor.removeAllXHRBreakpoints();
+ }
+
+ // The thread actor has to be initialized in order to correctly
+ // retrieve the stack trace when hitting an XHR
+ if (
+ threadActor.state == THREAD_STATES.DETACHED &&
+ !targetActor.targetType.endsWith("worker")
+ ) {
+ await threadActor.attach();
+ }
+
+ await Promise.all(
+ entries.map(({ path, method }) =>
+ threadActor.setXHRBreakpoint(path, method)
+ )
+ );
+ },
+
+ removeSessionDataEntry(targetActor, entries, isDocumentCreation) {
+ for (const { path, method } of entries) {
+ targetActor.threadActor.removeXHRBreakpoint(path, method);
+ }
+ },
+};
diff --git a/devtools/server/actors/targets/target-actor-registry.sys.mjs b/devtools/server/actors/targets/target-actor-registry.sys.mjs
new file mode 100644
index 0000000000..4cb6d13868
--- /dev/null
+++ b/devtools/server/actors/targets/target-actor-registry.sys.mjs
@@ -0,0 +1,82 @@
+/* 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/. */
+
+// Keep track of all WindowGlobal target actors.
+// This is especially used to track the actors using Message manager connector,
+// or the ones running in the parent process.
+// Top level actors, like tab's top level target or parent process target
+// are still using message manager in order to avoid being destroyed on navigation.
+// And because of this, these actors aren't using JS Window Actor.
+const windowGlobalTargetActors = new Set();
+let xpcShellTargetActor = null;
+
+export var TargetActorRegistry = {
+ registerTargetActor(targetActor) {
+ windowGlobalTargetActors.add(targetActor);
+ },
+
+ unregisterTargetActor(targetActor) {
+ windowGlobalTargetActors.delete(targetActor);
+ },
+
+ registerXpcShellTargetActor(targetActor) {
+ xpcShellTargetActor = targetActor;
+ },
+
+ unregisterXpcShellTargetActor(targetActor) {
+ xpcShellTargetActor = null;
+ },
+
+ get xpcShellTargetActor() {
+ return xpcShellTargetActor;
+ },
+
+ /**
+ * Return the target actors matching the passed browser element id.
+ * In some scenarios, the registry can have multiple target actors for a given
+ * browserId (e.g. the regular DevTools content toolbox + DevTools WebExtensions targets).
+ *
+ * @param {Object} sessionContext: The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * @param {String} connectionPrefix: DevToolsServerConnection's prefix, in order to select only actor
+ * related to the same connection. i.e. the same client.
+ * @returns {Array<TargetActor>}
+ */
+ getTargetActors(sessionContext, connectionPrefix) {
+ const actors = [];
+ for (const actor of windowGlobalTargetActors) {
+ const isMatchingPrefix = actor.actorID.startsWith(connectionPrefix);
+ const isMatchingContext =
+ sessionContext.type == "all" ||
+ (sessionContext.type == "browser-element" &&
+ (actor.browserId == sessionContext.browserId ||
+ actor.openerBrowserId == sessionContext.browserId)) ||
+ (sessionContext.type == "webextension" &&
+ actor.addonId == sessionContext.addonId);
+ if (isMatchingPrefix && isMatchingContext) {
+ actors.push(actor);
+ }
+ }
+ return actors;
+ },
+
+ /**
+ * Helper for tests to help track the number of targets created for a given tab.
+ * (Used by browser_ext_devtools_inspectedWindow.js)
+ *
+ * @param {Number} browserId: ID for the tab
+ *
+ * @returns {Number} Number of targets for this tab.
+ */
+
+ getTargetActorsCountForBrowserElement(browserId) {
+ let count = 0;
+ for (const actor of windowGlobalTargetActors) {
+ if (actor.browserId == browserId) {
+ count++;
+ }
+ }
+ return count;
+ },
+};
diff --git a/devtools/server/actors/targets/webextension.js b/devtools/server/actors/targets/webextension.js
new file mode 100644
index 0000000000..c717b53011
--- /dev/null
+++ b/devtools/server/actors/targets/webextension.js
@@ -0,0 +1,374 @@
+/* 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";
+
+/*
+ * Target actor for a WebExtension add-on.
+ *
+ * This actor extends ParentProcessTargetActor.
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ */
+
+const {
+ ParentProcessTargetActor,
+} = require("resource://devtools/server/actors/targets/parent-process.js");
+const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js");
+const {
+ webExtensionTargetSpec,
+} = require("resource://devtools/shared/specs/targets/webextension.js");
+
+const {
+ getChildDocShells,
+} = require("resource://devtools/server/actors/targets/window-global.js");
+
+loader.lazyRequireGetter(
+ this,
+ "unwrapDebuggerObjectGlobal",
+ "resource://devtools/server/actors/thread.js",
+ true
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ getAddonIdForWindowGlobal:
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
+});
+
+const FALLBACK_DOC_URL =
+ "chrome://devtools/content/shared/webextension-fallback.html";
+
+class WebExtensionTargetActor extends ParentProcessTargetActor {
+ /**
+ * Creates a target actor for debugging all the contexts associated to a target
+ * WebExtensions add-on running in a child extension process. Most of the implementation
+ * is inherited from ParentProcessTargetActor (which inherits most of its implementation
+ * from WindowGlobalTargetActor).
+ *
+ * WebExtensionTargetActor is created by a WebExtensionActor counterpart, when its
+ * parent actor's `connect` method has been called (on the listAddons RDP package),
+ * it runs in the same process that the extension is running into (which can be the main
+ * process if the extension is running in non-oop mode, or the child extension process
+ * if the extension is running in oop-mode).
+ *
+ * A WebExtensionTargetActor contains all target-scoped actors, like a regular
+ * ParentProcessTargetActor or WindowGlobalTargetActor.
+ *
+ * History lecture:
+ * - The add-on actors used to not inherit WindowGlobalTargetActor because of the
+ * different way the add-on APIs where exposed to the add-on itself, and for this reason
+ * the Addon Debugger has only a sub-set of the feature available in the Tab or in the
+ * Browser Toolbox.
+ * - In a WebExtensions add-on all the provided contexts (background, popups etc.),
+ * besides the Content Scripts which run in the content process, hooked to an existent
+ * tab, by creating a new WebExtensionActor which inherits from
+ * ParentProcessTargetActor, we can provide a full features Addon Toolbox (which is
+ * basically like a BrowserToolbox which filters the visible sources and frames to the
+ * one that are related to the target add-on).
+ * - When the WebExtensions OOP mode has been introduced, this actor has been refactored
+ * and moved from the main process to the new child extension process.
+ *
+ * @param {DevToolsServerConnection} conn
+ * The connection to the client.
+ * @param {nsIMessageSender} chromeGlobal.
+ * The chromeGlobal where this actor has been injected by the
+ * frame-connector.js connectToFrame method.
+ * @param {Object} options
+ * - addonId: {String} the addonId of the target WebExtension.
+ * - addonBrowsingContextGroupId: {String} the BrowsingContextGroupId used by this addon.
+ * - chromeGlobal: {nsIMessageSender} The chromeGlobal where this actor
+ * has been injected by the frame-connector.js connectToFrame method.
+ * - isTopLevelTarget: {Boolean} flag to indicate if this is the top
+ * level target of the DevTools session
+ * - prefix: {String} the custom RDP prefix to use.
+ * - sessionContext Object
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ */
+ constructor(
+ conn,
+ {
+ addonId,
+ addonBrowsingContextGroupId,
+ chromeGlobal,
+ isTopLevelTarget,
+ prefix,
+ sessionContext,
+ }
+ ) {
+ super(conn, {
+ isTopLevelTarget,
+ sessionContext,
+ customSpec: webExtensionTargetSpec,
+ });
+
+ this.addonId = addonId;
+ this.addonBrowsingContextGroupId = addonBrowsingContextGroupId;
+ this._chromeGlobal = chromeGlobal;
+ this._prefix = prefix;
+
+ // Expose the BrowsingContext of the fallback document,
+ // which is the one this target actor will always refer to via its form()
+ // and all resources should be related to this one as we currently spawn
+ // only just this one target actor to debug all webextension documents.
+ this.devtoolsSpawnedBrowsingContextForWebExtension =
+ chromeGlobal.browsingContext;
+
+ // Redefine the messageManager getter to return the chromeGlobal
+ // as the messageManager for this actor (which is the browser XUL
+ // element used by the parent actor running in the main process to
+ // connect to the extension process).
+ Object.defineProperty(this, "messageManager", {
+ enumerable: true,
+ configurable: true,
+ get: () => {
+ return this._chromeGlobal;
+ },
+ });
+
+ this._onParentExit = this._onParentExit.bind(this);
+
+ this._chromeGlobal.addMessageListener(
+ "debug:webext_parent_exit",
+ this._onParentExit
+ );
+
+ // Set the consoleAPIListener filtering options
+ // (retrieved and used in the related webconsole child actor).
+ this.consoleAPIListenerOptions = {
+ addonId: this.addonId,
+ };
+
+ // This creates a Debugger instance for debugging all the add-on globals.
+ this.makeDebugger = makeDebugger.bind(null, {
+ findDebuggees: dbg => {
+ return dbg
+ .findAllGlobals()
+ .filter(this._shouldAddNewGlobalAsDebuggee)
+ .map(g => g.unsafeDereference());
+ },
+ shouldAddNewGlobalAsDebuggee:
+ this._shouldAddNewGlobalAsDebuggee.bind(this),
+ });
+
+ // NOTE: This is needed to catch in the webextension webconsole all the
+ // errors raised by the WebExtension internals that are not currently
+ // associated with any window.
+ this.isRootActor = true;
+
+ // Try to discovery an existent extension page to attach (which will provide the initial
+ // URL shown in the window tittle when the addon debugger is opened).
+ const extensionWindow = this._searchForExtensionWindow();
+ this.setDocShell(extensionWindow.docShell);
+ }
+
+ // Override the ParentProcessTargetActor's override in order to only iterate
+ // over the docshells specific to this add-on
+ get docShells() {
+ // Iterate over all top-level windows and all their docshells.
+ let docShells = [];
+ for (const window of Services.ww.getWindowEnumerator(null)) {
+ docShells = docShells.concat(getChildDocShells(window.docShell));
+ }
+ // Then filter out the ones specific to the add-on
+ return docShells.filter(docShell => {
+ return this.isExtensionWindowDescendent(docShell.domWindow);
+ });
+ }
+
+ /**
+ * Called when the actor is removed from the connection.
+ */
+ destroy() {
+ if (this._chromeGlobal) {
+ const chromeGlobal = this._chromeGlobal;
+ this._chromeGlobal = null;
+
+ chromeGlobal.removeMessageListener(
+ "debug:webext_parent_exit",
+ this._onParentExit
+ );
+
+ chromeGlobal.sendAsyncMessage("debug:webext_child_exit", {
+ actor: this.actorID,
+ });
+ }
+
+ if (this.fallbackWindow) {
+ this.fallbackWindow = null;
+ }
+
+ this.addon = null;
+ this.addonId = null;
+
+ return super.destroy();
+ }
+
+ // Private helpers.
+
+ _searchFallbackWindow() {
+ if (this.fallbackWindow) {
+ // Skip if there is already an existent fallback window.
+ return this.fallbackWindow;
+ }
+
+ // Set and initialize the fallbackWindow (which initially is a empty
+ // about:blank browser), this window is related to a XUL browser element
+ // specifically created for the devtools server and it is never used
+ // or navigated anywhere else.
+ this.fallbackWindow = this._chromeGlobal.content;
+
+ // Add the addonId in the URL to retrieve this information in other devtools
+ // helpers. The addonId is usually populated in the principal, but this will
+ // not be the case for the fallback window because it is loaded from chrome://
+ // instead of moz-extension://${addonId}
+ this.fallbackWindow.document.location.href = `${FALLBACK_DOC_URL}#${this.addonId}`;
+
+ return this.fallbackWindow;
+ }
+
+ // Discovery an extension page to use as a default target window.
+ // NOTE: This currently fail to discovery an extension page running in a
+ // windowless browser when running in non-oop mode, and the background page
+ // is set later using _onNewExtensionWindow.
+ _searchForExtensionWindow() {
+ // Looks if there is any top level add-on document:
+ // (we do not want to pass any nested add-on iframe)
+ const docShell = this.docShells.find(d =>
+ this.isTopLevelExtensionWindow(d.domWindow)
+ );
+ if (docShell) {
+ return docShell.domWindow;
+ }
+
+ return this._searchFallbackWindow();
+ }
+
+ // Customized ParentProcessTargetActor/WindowGlobalTargetActor hooks.
+
+ _onDocShellCreated(docShell) {
+ // Compare against the BrowsingContext's group ID as the document's principal addonId
+ // won't be set yet for freshly created docshells. It will be later set, when loading the addon URL.
+ // But right now, it is still on the initial about:blank document and the principal isn't related to the add-on.
+ if (docShell.browsingContext.group.id != this.addonBrowsingContextGroupId) {
+ return;
+ }
+ super._onDocShellCreated(docShell);
+ }
+
+ _onDocShellDestroy(docShell) {
+ if (docShell.browsingContext.group.id != this.addonBrowsingContextGroupId) {
+ return;
+ }
+ // Stop watching this docshell (the unwatch() method will check if we
+ // started watching it before).
+ this._unwatchDocShell(docShell);
+
+ // Let the _onDocShellDestroy notify that the docShell has been destroyed.
+ const webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ this._notifyDocShellDestroy(webProgress);
+
+ // If the destroyed docShell:
+ // * was the current docShell,
+ // * the actor is not destroyed,
+ // * isn't the background page, as it means the addon is being shutdown or reloaded
+ // and the target would be replaced by a new one to come, or everything is closing.
+ // => switch to the fallback window
+ if (
+ !this.isDestroyed() &&
+ docShell == this.docShell &&
+ !docShell.domWindow.location.href.includes(
+ "_generated_background_page.html"
+ )
+ ) {
+ this._changeTopLevelDocument(this._searchForExtensionWindow());
+ }
+ }
+
+ _onNewExtensionWindow(window) {
+ if (!this.window || this.window === this.fallbackWindow) {
+ this._changeTopLevelDocument(window);
+ // For new extension windows, the BrowsingContext group id might have
+ // changed, for instance when reloading the addon.
+ this.addonBrowsingContextGroupId =
+ window.docShell.browsingContext.group.id;
+ }
+ }
+
+ isTopLevelExtensionWindow(window) {
+ const { docShell } = window;
+ const isTopLevel = docShell.sameTypeRootTreeItem == docShell;
+ // Note: We are not using getAddonIdForWindowGlobal here because the
+ // fallback window should not be considered as a top level extension window.
+ return isTopLevel && window.document.nodePrincipal.addonId == this.addonId;
+ }
+
+ isExtensionWindowDescendent(window) {
+ // Check if the source is coming from a descendant docShell of an extension window.
+ // We may have an iframe that loads http content which won't use the add-on principal.
+ const rootWin = window.docShell.sameTypeRootTreeItem.domWindow;
+ const addonId = lazy.getAddonIdForWindowGlobal(rootWin.windowGlobalChild);
+ return addonId == this.addonId;
+ }
+
+ /**
+ * Return true if the given global is associated with this addon and should be
+ * added as a debuggee, false otherwise.
+ */
+ _shouldAddNewGlobalAsDebuggee(newGlobal) {
+ const global = unwrapDebuggerObjectGlobal(newGlobal);
+
+ if (global instanceof Ci.nsIDOMWindow) {
+ try {
+ global.document;
+ } catch (e) {
+ // The global might be a sandbox with a window object in its proto chain. If the
+ // window navigated away since the sandbox was created, it can throw a security
+ // exception during this property check as the sandbox no longer has access to
+ // its own proto.
+ return false;
+ }
+ // When `global` is a sandbox it may be a nsIDOMWindow object,
+ // but won't be the real Window object. Retrieve it via document's ownerGlobal.
+ const window = global.document.ownerGlobal;
+ if (!window) {
+ return false;
+ }
+
+ // Change top level document as a simulated frame switching.
+ if (this.isTopLevelExtensionWindow(window)) {
+ this._onNewExtensionWindow(window);
+ }
+
+ return this.isExtensionWindowDescendent(window);
+ }
+
+ try {
+ // This will fail for non-Sandbox objects, hence the try-catch block.
+ const metadata = Cu.getSandboxMetadata(global);
+ if (metadata) {
+ return metadata.addonID === this.addonId;
+ }
+ } catch (e) {
+ // Unable to retrieve the sandbox metadata.
+ }
+
+ return false;
+ }
+
+ // Handlers for the messages received from the parent actor.
+
+ _onParentExit(msg) {
+ if (msg.json.actor !== this.actorID) {
+ return;
+ }
+
+ this.destroy();
+ }
+}
+
+exports.WebExtensionTargetActor = WebExtensionTargetActor;
diff --git a/devtools/server/actors/targets/window-global.js b/devtools/server/actors/targets/window-global.js
new file mode 100644
index 0000000000..5d2bb10164
--- /dev/null
+++ b/devtools/server/actors/targets/window-global.js
@@ -0,0 +1,1935 @@
+/* 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";
+
+// protocol.js uses objects as exceptions in order to define
+// error packets.
+/* eslint-disable no-throw-literal */
+
+/*
+ * WindowGlobalTargetActor is an abstract class used by target actors that hold
+ * documents, such as frames, chrome windows, etc.
+ *
+ * This class is extended by ParentProcessTargetActor, itself being extented by WebExtensionTargetActor.
+ *
+ * See devtools/docs/backend/actor-hierarchy.md for more details.
+ *
+ * For performance matters, this file should only be loaded in the targeted context's
+ * process. For example, it shouldn't be evaluated in the parent process until we try to
+ * debug a document living in the parent process.
+ */
+
+var {
+ ActorRegistry,
+} = require("resource://devtools/server/actors/utils/actor-registry.js");
+var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+var { assert } = DevToolsUtils;
+var {
+ SourcesManager,
+} = require("resource://devtools/server/actors/utils/sources-manager.js");
+var makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+const { TargetActorRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs",
+ {
+ loadInDevToolsLoader: false,
+ }
+);
+const { PrivateBrowsingUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"
+);
+
+const EXTENSION_CONTENT_SYS_MJS =
+ "resource://gre/modules/ExtensionContent.sys.mjs";
+
+const { Pool } = require("resource://devtools/shared/protocol.js");
+const {
+ LazyPool,
+ createExtraActors,
+} = require("resource://devtools/shared/protocol/lazy-pool.js");
+const {
+ windowGlobalTargetSpec,
+} = require("resource://devtools/shared/specs/targets/window-global.js");
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+const {
+ BaseTargetActor,
+} = require("resource://devtools/server/actors/targets/base-target-actor.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["ThreadActor", "unwrapDebuggerObjectGlobal"],
+ "resource://devtools/server/actors/thread.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WorkerDescriptorActorList",
+ "resource://devtools/server/actors/worker/worker-descriptor-actor-list.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "StyleSheetsManager",
+ "resource://devtools/server/actors/utils/stylesheets-manager.js",
+ true
+);
+const lazy = {};
+loader.lazyGetter(lazy, "ExtensionContent", () => {
+ return ChromeUtils.importESModule(EXTENSION_CONTENT_SYS_MJS, {
+ // ExtensionContent.sys.mjs is a singleton and must be loaded through the
+ // main loader. Note that the user of lazy.ExtensionContent elsewhere in
+ // this file (at webextensionsContentScriptGlobals) looks up the module
+ // via Cu.isESModuleLoaded, which also uses the main loader as desired.
+ loadInDevToolsLoader: false,
+ }).ExtensionContent;
+});
+
+loader.lazyRequireGetter(
+ this,
+ "TouchSimulator",
+ "resource://devtools/server/actors/emulation/touch-simulator.js",
+ true
+);
+
+function getWindowID(window) {
+ return window.windowGlobalChild.innerWindowId;
+}
+
+function getDocShellChromeEventHandler(docShell) {
+ let handler = docShell.chromeEventHandler;
+ if (!handler) {
+ try {
+ // Toplevel xul window's docshell doesn't have chromeEventHandler
+ // attribute. The chrome event handler is just the global window object.
+ handler = docShell.domWindow;
+ } catch (e) {
+ // ignore
+ }
+ }
+ return handler;
+}
+
+/**
+ * Helper to retrieve all children docshells of a given docshell.
+ *
+ * Given that docshell interfaces can only be used within the same process,
+ * this only returns docshells for children documents that runs in the same process
+ * as the given docshell.
+ */
+function getChildDocShells(parentDocShell) {
+ return parentDocShell.browsingContext
+ .getAllBrowsingContextsInSubtree()
+ .filter(browsingContext => {
+ // Filter out browsingContext which don't expose any docshell (e.g. remote frame)
+ return browsingContext.docShell;
+ })
+ .map(browsingContext => {
+ // Map BrowsingContext to DocShell
+ return browsingContext.docShell;
+ });
+}
+
+exports.getChildDocShells = getChildDocShells;
+
+/**
+ * Browser-specific actors.
+ */
+
+function getInnerId(window) {
+ return window.windowGlobalChild.innerWindowId;
+}
+
+class WindowGlobalTargetActor extends BaseTargetActor {
+ /**
+ * WindowGlobalTargetActor is the target actor to debug (HTML) documents.
+ *
+ * WindowGlobal's are the Gecko representation for a given document's window object.
+ * It relates to a given nsGlobalWindowInner instance.
+ *
+ * The main goal of this class is to expose the target-scoped actors being registered
+ * via `ActorRegistry.registerModule` and manage their lifetimes. In addition, this
+ * class also tracks the lifetime of the targeted window global.
+ *
+ * ### Main requests:
+ *
+ * `detach`:
+ * Stop document watching and cleanup everything that the target and its children actors created.
+ * It ultimately lead to destroy the target actor.
+ * `switchToFrame`:
+ * Change the targeted document of the whole actor, and its child target-scoped actors
+ * to an iframe or back to its original document.
+ *
+ * Most properties (like `chromeEventHandler` or `docShells`) are meant to be
+ * used by the various child target actors.
+ *
+ * ### RDP events:
+ *
+ * - `tabNavigated`:
+ * Sent when the window global is about to navigate or has just navigated
+ * to a different document.
+ * This event contains the following attributes:
+ * * url (string)
+ * The new URI being loaded.
+ * * state (string)
+ * `start` if we just start requesting the new URL
+ * `stop` if the new URL is done loading
+ * * isFrameSwitching (boolean)
+ * Indicates the event is dispatched when switching the actor context to a
+ * different frame. When we switch to an iframe, there is no document
+ * load. The targeted document is most likely going to be already done
+ * loading.
+ * * title (string)
+ * The document title being loaded. (sent only on state=stop)
+ *
+ * - `frameUpdate`:
+ * Sent when there was a change in the child frames contained in the document
+ * or when the actor's context was switched to another frame.
+ * This event can have four different forms depending on the type of change:
+ * * One or many frames are updated:
+ * { frames: [{ id, url, title, parentID }, ...] }
+ * * One frame got destroyed:
+ * { frames: [{ id, destroy: true }]}
+ * * All frames got destroyed:
+ * { destroyAll: true }
+ * * We switched the context of the actor to a specific frame:
+ * { selected: #id }
+ *
+ * ### Internal, non-rdp events:
+ *
+ * Various events are also dispatched on the actor itself without being sent to
+ * the client. They all relate to the documents tracked by this target actor
+ * (its main targeted document, but also any of its iframes):
+ * - will-navigate
+ * This event fires once navigation starts. All pending user prompts are
+ * dealt with, but it is fired before the first request starts.
+ * - navigate
+ * This event is fired once the document's readyState is "complete".
+ * - window-ready
+ * This event is fired in various distinct scenarios:
+ * * When a new Window object is crafted, equivalent of `DOMWindowCreated`.
+ * It is dispatched before any page script is executed.
+ * * We will have already received a window-ready event for this window
+ * when it was created, but we received a window-destroyed event when
+ * it was frozen into the bfcache, and now the user navigated back to
+ * this page, so it's now live again and we should resume handling it.
+ * * For each existing document, when an `attach` request is received.
+ * At this point scripts in the page will be already loaded.
+ * * When `swapFrameLoaders` is used, such as with moving window globals
+ * between windows or toggling Responsive Design Mode.
+ * - window-destroyed
+ * This event is fired in two cases:
+ * * When the window object is destroyed, i.e. when the related document
+ * is garbage collected. This can happen when the window global is
+ * closed or the iframe is removed from the DOM.
+ * It is equivalent of `inner-window-destroyed` event.
+ * * When the page goes into the bfcache and gets frozen.
+ * The equivalent of `pagehide`.
+ * - changed-toplevel-document
+ * This event fires when we switch the actor's targeted document
+ * to one of its iframes, or back to its original top document.
+ * It is dispatched between window-destroyed and window-ready.
+ *
+ * Note that *all* these events are dispatched in the following order
+ * when we switch the context of the actor to a given iframe:
+ * - will-navigate
+ * - window-destroyed
+ * - changed-toplevel-document
+ * - window-ready
+ * - navigate
+ *
+ * This class is subclassed by ParentProcessTargetActor and others.
+ * Subclasses are expected to implement a getter for the docShell property.
+ *
+ * @param conn DevToolsServerConnection
+ * The conection to the client.
+ * @param options Object
+ * Object with following attributes:
+ * - docShell nsIDocShell
+ * The |docShell| for the debugged frame.
+ * - followWindowGlobalLifeCycle Boolean
+ * If true, the target actor will only inspect the current WindowGlobal (and its children windows).
+ * But won't inspect next document loaded in the same BrowsingContext.
+ * The actor will behave more like a WindowGlobalTarget rather than a BrowsingContextTarget.
+ * This is always true for Tab debugging, but not yet for parent process/web extension.
+ * - isTopLevelTarget Boolean
+ * Should be set to true for all top-level targets. A top level target
+ * is the topmost target of a DevTools "session". For instance for a local
+ * tab toolbox, the WindowGlobalTargetActor for the content page is the top level target.
+ * For the Multiprocess Browser Toolbox, the parent process target is the top level
+ * target.
+ * At the moment this only impacts the WindowGlobalTarget `reconfigure`
+ * implementation. But for server-side target switching this flag will be exposed
+ * to the client and should be available for all target actor classes. It will be
+ * used to detect target switching. (Bug 1644397)
+ * - ignoreSubFrames Boolean
+ * If true, the actor will only focus on the passed docShell and not on the whole
+ * docShell tree. This should be enabled when we have targets for all documents.
+ * - sessionContext Object
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ */
+ constructor(
+ conn,
+ {
+ docShell,
+ followWindowGlobalLifeCycle,
+ isTopLevelTarget,
+ ignoreSubFrames,
+ sessionContext,
+ customSpec = windowGlobalTargetSpec,
+ }
+ ) {
+ super(conn, Targets.TYPES.FRAME, customSpec);
+
+ this.followWindowGlobalLifeCycle = followWindowGlobalLifeCycle;
+ this.isTopLevelTarget = !!isTopLevelTarget;
+ this.ignoreSubFrames = ignoreSubFrames;
+ this.sessionContext = sessionContext;
+
+ // A map of actor names to actor instances provided by extensions.
+ this._extraActors = {};
+ this._sourcesManager = null;
+
+ this._shouldAddNewGlobalAsDebuggee =
+ this._shouldAddNewGlobalAsDebuggee.bind(this);
+
+ this.makeDebugger = makeDebugger.bind(null, {
+ findDebuggees: () => {
+ const result = [];
+ const inspectUAWidgets = Services.prefs.getBoolPref(
+ "devtools.inspector.showAllAnonymousContent",
+ false
+ );
+ for (const win of this.windows) {
+ result.push(win);
+ // Only expose User Agent internal (like <video controls>) when the
+ // related pref is set.
+ if (inspectUAWidgets) {
+ const principal = win.document.nodePrincipal;
+ // We don't use UA widgets for the system principal.
+ if (!principal.isSystemPrincipal) {
+ result.push(Cu.getUAWidgetScope(principal));
+ }
+ }
+ }
+ return result.concat(this.webextensionsContentScriptGlobals);
+ },
+ shouldAddNewGlobalAsDebuggee: this._shouldAddNewGlobalAsDebuggee,
+ });
+
+ // Flag eventually overloaded by sub classes in order to watch new docshells
+ // Used by the ParentProcessTargetActor to list all frames in the Browser Toolbox
+ this.watchNewDocShells = false;
+
+ this._workerDescriptorActorList = null;
+ this._workerDescriptorActorPool = null;
+ this._onWorkerDescriptorActorListChanged =
+ this._onWorkerDescriptorActorListChanged.bind(this);
+
+ this._onConsoleApiProfilerEvent =
+ this._onConsoleApiProfilerEvent.bind(this);
+ Services.obs.addObserver(
+ this._onConsoleApiProfilerEvent,
+ "console-api-profiler"
+ );
+
+ // Start observing navigations as well as sub documents.
+ // (This is probably meant to disappear once EFT is the only supported codepath)
+ this._progressListener = new DebuggerProgressListener(this);
+
+ TargetActorRegistry.registerTargetActor(this);
+
+ if (docShell) {
+ this.setDocShell(docShell);
+ }
+ }
+
+ /**
+ * Define the initial docshell.
+ *
+ * This is called from the constructor for WindowGlobalTargetActor,
+ * or from sub class constructors: WebExtensionTargetActor and ParentProcessTargetActor.
+ *
+ * This is to circumvent the fact that sub classes need to call inner method
+ * to compute the initial docshell and we can't call inner methods before calling
+ * the base class constructor...
+ */
+ setDocShell(docShell) {
+ Object.defineProperty(this, "docShell", {
+ value: docShell,
+ configurable: true,
+ writable: true,
+ });
+
+ // Save references to the original document we attached to
+ this._originalWindow = this.window;
+
+ // Update isPrivate as window is based on docShell
+ this.isPrivate = PrivateBrowsingUtils.isContentWindowPrivate(this.window);
+
+ // Instantiate the Thread Actor immediately.
+ // This is the only one actor instantiated right away by the target actor.
+ // All the others are instantiated lazily on first request made the client,
+ // via LazyPool API.
+ this._createThreadActor();
+
+ // Ensure notifying about the target actor first
+ // before notifying about new docshells.
+ // Otherwise we would miss these RDP event as the client hasn't
+ // yet received the target actor's form.
+ // (This is also probably meant to disappear once EFT is the only supported codepath)
+ this._docShellsObserved = false;
+ DevToolsUtils.executeSoon(() => this._watchDocshells());
+ }
+
+ get docShell() {
+ throw new Error(
+ "A docShell should be provided as constructor argument of WindowGlobalTargetActor, or redefined by the subclass"
+ );
+ }
+
+ // Optional console API listener options (e.g. used by the WebExtensionActor to
+ // filter console messages by addonID), set to an empty (no options) object by default.
+ consoleAPIListenerOptions = {};
+
+ /*
+ * Return a Debugger instance or create one if there is none yet
+ */
+ get dbg() {
+ if (!this._dbg) {
+ this._dbg = this.makeDebugger();
+ }
+ return this._dbg;
+ }
+
+ /**
+ * Try to locate the console actor if it exists.
+ */
+ get _consoleActor() {
+ if (this.isDestroyed()) {
+ return null;
+ }
+ const form = this.form();
+ return this.conn._getOrCreateActor(form.consoleActor);
+ }
+
+ get _memoryActor() {
+ if (this.isDestroyed()) {
+ return null;
+ }
+ const form = this.form();
+ return this.conn._getOrCreateActor(form.memoryActor);
+ }
+
+ _targetScopedActorPool = null;
+
+ /**
+ * An object on which listen for DOMWindowCreated and pageshow events.
+ */
+ get chromeEventHandler() {
+ return getDocShellChromeEventHandler(this.docShell);
+ }
+
+ /**
+ * Getter for the nsIMessageManager associated to the window global.
+ */
+ get messageManager() {
+ try {
+ return this.docShell.messageManager;
+ } catch (e) {
+ // In some cases we can't get a docshell. We just have no message manager
+ // then,
+ return null;
+ }
+ }
+
+ /**
+ * Getter for the list of all `docShell`s in the window global.
+ * @return {Array}
+ */
+ get docShells() {
+ if (this.ignoreSubFrames) {
+ return [this.docShell];
+ }
+
+ return getChildDocShells(this.docShell);
+ }
+
+ /**
+ * Getter for the window global's current DOM window.
+ */
+ get window() {
+ return this.docShell && !this.docShell.isBeingDestroyed()
+ ? this.docShell.domWindow
+ : null;
+ }
+
+ get outerWindowID() {
+ if (this.docShell) {
+ return this.docShell.outerWindowID;
+ }
+ return null;
+ }
+
+ get browsingContext() {
+ return this.docShell?.browsingContext;
+ }
+
+ get browsingContextID() {
+ return this.browsingContext?.id;
+ }
+
+ get browserId() {
+ return this.browsingContext?.browserId;
+ }
+
+ get openerBrowserId() {
+ return this.browsingContext?.opener?.browserId;
+ }
+
+ /**
+ * Getter for the WebExtensions ContentScript globals related to the
+ * window global's current DOM window.
+ */
+ get webextensionsContentScriptGlobals() {
+ // Only retrieve the content scripts globals if the ExtensionContent JSM module
+ // has been already loaded (which is true if the WebExtensions internals have already
+ // been loaded in the same content process).
+ if (Cu.isESModuleLoaded(EXTENSION_CONTENT_SYS_MJS)) {
+ return lazy.ExtensionContent.getContentScriptGlobals(this.window);
+ }
+
+ return [];
+ }
+
+ /**
+ * Getter for the list of all content DOM windows in the window global.
+ * @return {Array}
+ */
+ get windows() {
+ return this.docShells.map(docShell => {
+ return docShell.domWindow;
+ });
+ }
+
+ /**
+ * Getter for the original docShell this actor got attached to in the first
+ * place.
+ * Note that your actor should normally *not* rely on this top level docShell
+ * if you want it to show information relative to the iframe that's currently
+ * being inspected in the toolbox.
+ */
+ get originalDocShell() {
+ if (!this._originalWindow) {
+ return this.docShell;
+ }
+
+ return this._originalWindow.docShell;
+ }
+
+ /**
+ * Getter for the original window this actor got attached to in the first
+ * place.
+ * Note that your actor should normally *not* rely on this top level window if
+ * you want it to show information relative to the iframe that's currently
+ * being inspected in the toolbox.
+ */
+ get originalWindow() {
+ return this._originalWindow || this.window;
+ }
+
+ /**
+ * Getter for the nsIWebProgress for watching this window.
+ */
+ get webProgress() {
+ return this.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ }
+
+ /**
+ * Getter for the nsIWebNavigation for the target.
+ */
+ get webNavigation() {
+ return this.docShell.QueryInterface(Ci.nsIWebNavigation);
+ }
+
+ /**
+ * Getter for the window global's document.
+ */
+ get contentDocument() {
+ return this.webNavigation.document;
+ }
+
+ /**
+ * Getter for the window global's title.
+ */
+ get title() {
+ return this.contentDocument.title;
+ }
+
+ /**
+ * Getter for the window global's URL.
+ */
+ get url() {
+ if (this.webNavigation.currentURI) {
+ return this.webNavigation.currentURI.spec;
+ }
+ // Abrupt closing of the browser window may leave callbacks without a
+ // currentURI.
+ return null;
+ }
+
+ get sourcesManager() {
+ if (!this._sourcesManager) {
+ this._sourcesManager = new SourcesManager(this.threadActor);
+ }
+ return this._sourcesManager;
+ }
+
+ getStyleSheetsManager() {
+ if (!this._styleSheetsManager) {
+ this._styleSheetsManager = new StyleSheetsManager(this);
+ }
+ return this._styleSheetsManager;
+ }
+
+ _createExtraActors() {
+ // Always use the same Pool, so existing actor instances
+ // (created in createExtraActors) are not lost.
+ if (!this._targetScopedActorPool) {
+ this._targetScopedActorPool = new LazyPool(this.conn);
+ }
+
+ // Walk over target-scoped actor factories and make sure they are all
+ // instantiated and added into the Pool.
+ return createExtraActors(
+ ActorRegistry.targetScopedActorFactories,
+ this._targetScopedActorPool,
+ this
+ );
+ }
+
+ form() {
+ assert(
+ !this.isDestroyed(),
+ "form() shouldn't be called on destroyed browser actor."
+ );
+ assert(this.actorID, "Actor should have an actorID.");
+
+ // Note that we don't want the iframe dropdown to change our BrowsingContext.id/innerWindowId
+ // We only want to refer to the topmost original window we attached to
+ // as that's the one top document this target actor really represent.
+ // The iframe dropdown is just a hack that temporarily focus the scope
+ // of the target actor to a children iframe document.
+ //
+ // Also, for WebExtension, we want the target to represent the <browser> element
+ // created by DevTools, which always exists and help better connect resources to the target
+ // in the frontend. Otherwise all other <browser> element of webext may be reloaded or go away
+ // and then we would have troubles matching targets for resources.
+ const originalBrowsingContext = this
+ .devtoolsSpawnedBrowsingContextForWebExtension
+ ? this.devtoolsSpawnedBrowsingContextForWebExtension
+ : this.originalDocShell.browsingContext;
+ const browsingContextID = originalBrowsingContext.id;
+ const innerWindowId =
+ originalBrowsingContext.currentWindowContext.innerWindowId;
+ const parentInnerWindowId =
+ originalBrowsingContext.parent?.currentWindowContext.innerWindowId;
+ // Doesn't only check `!!opener` as some iframe might have an opener
+ // if their location was loaded via `window.open(url, "iframe-name")`.
+ // So also ensure that the document is opened in a distinct tab.
+ const isPopup =
+ !!originalBrowsingContext.opener &&
+ originalBrowsingContext.browserId !=
+ originalBrowsingContext.opener.browserId;
+
+ const response = {
+ actor: this.actorID,
+ browsingContextID,
+ processID: Services.appinfo.processID,
+ // True for targets created by JSWindowActors, see constructor JSDoc.
+ followWindowGlobalLifeCycle: this.followWindowGlobalLifeCycle,
+ innerWindowId,
+ parentInnerWindowId,
+ topInnerWindowId: this.browsingContext.topWindowContext.innerWindowId,
+ isTopLevelTarget: this.isTopLevelTarget,
+ ignoreSubFrames: this.ignoreSubFrames,
+ isPopup,
+ isPrivate: this.isPrivate,
+ traits: {
+ // @backward-compat { version 64 } Exposes a new trait to help identify
+ // BrowsingContextActor's inherited actors from the client side.
+ isBrowsingContext: true,
+ // Browsing context targets can compute the isTopLevelTarget flag on the
+ // server. But other target actors don't support this yet. See Bug 1709314.
+ supportsTopLevelTargetFlag: true,
+ // Supports frame listing via `listFrames` request and `frameUpdate` events
+ // as well as frame switching via `switchToFrame` request
+ frames: true,
+ // Supports the logInPage request.
+ logInPage: true,
+ // Supports watchpoints in the server. We need to keep this trait because target
+ // actors that don't extend WindowGlobalTargetActor (Worker, ContentProcess, …)
+ // might not support watchpoints.
+ watchpoints: true,
+ // Supports back and forward navigation
+ navigation: true,
+ },
+ };
+
+ // We may try to access window while the document is closing, then accessing window
+ // throws.
+ if (!this.docShell.isBeingDestroyed()) {
+ response.title = this.title;
+ response.url = this.url;
+ response.outerWindowID = this.outerWindowID;
+ }
+
+ const actors = this._createExtraActors();
+ Object.assign(response, actors);
+
+ // The thread actor is the only actor manually created by the target actor.
+ // It is not registered in targetScopedActorFactories and therefore needs
+ // to be added here manually.
+ if (this.threadActor) {
+ Object.assign(response, {
+ threadActor: this.threadActor.actorID,
+ });
+ }
+
+ return response;
+ }
+
+ /**
+ * Called when the actor is removed from the connection.
+ *
+ * @params {Object} options
+ * @params {Boolean} options.isTargetSwitching: Set to true when this is called during
+ * a target switch.
+ * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the
+ * result of a change to the devtools.browsertoolbox.scope pref.
+ */
+ destroy({ isTargetSwitching = false, isModeSwitching = false } = {}) {
+ // Avoid reentrancy. We will destroy the Transport when emitting "destroyed",
+ // which will force destroying all actors.
+ if (this.destroying) {
+ return;
+ }
+ this.destroying = true;
+
+ // Tell the thread actor that the window global is closed, so that it may terminate
+ // instead of resuming the debuggee script.
+ // TODO: Bug 997119: Remove this coupling with thread actor
+ if (this.threadActor) {
+ this.threadActor._parentClosed = true;
+ }
+
+ if (this._touchSimulator) {
+ this._touchSimulator.stop();
+ this._touchSimulator = null;
+ }
+
+ // Check for `docShell` availability, as it can be already gone during
+ // Firefox shutdown.
+ if (this.docShell) {
+ this._unwatchDocShell(this.docShell);
+
+ // If this target is being destroyed as part of a target switch or a mode switch,
+ // we don't need to restore the configuration (this might cause the content page to
+ // be focused again, causing issues in tests and disturbing the user when switching modes).
+ if (!isTargetSwitching && !isModeSwitching) {
+ this._restoreTargetConfiguration();
+ }
+ }
+ this._unwatchDocshells();
+
+ this._destroyThreadActor();
+
+ if (this._styleSheetsManager) {
+ this._styleSheetsManager.destroy();
+ this._styleSheetsManager = null;
+ }
+
+ // Shut down actors that belong to this target's pool.
+ if (this._targetScopedActorPool) {
+ this._targetScopedActorPool.destroy();
+ this._targetScopedActorPool = null;
+ }
+
+ // Make sure that no more workerListChanged notifications are sent.
+ if (this._workerDescriptorActorList !== null) {
+ this._workerDescriptorActorList.destroy();
+ this._workerDescriptorActorList = null;
+ }
+
+ if (this._workerDescriptorActorPool !== null) {
+ this._workerDescriptorActorPool.destroy();
+ this._workerDescriptorActorPool = null;
+ }
+
+ if (this._dbg) {
+ this._dbg.disable();
+ this._dbg = null;
+ }
+
+ // Emit a last event before calling Actor.destroy
+ // which will destroy the EventEmitter API
+ this.emit("destroyed", { isTargetSwitching, isModeSwitching });
+
+ // Destroy BaseTargetActor before nullifying docShell in case any child actor queries the window/docShell.
+ super.destroy();
+
+ this.docShell = null;
+ this._extraActors = null;
+
+ Services.obs.removeObserver(
+ this._onConsoleApiProfilerEvent,
+ "console-api-profiler"
+ );
+
+ TargetActorRegistry.unregisterTargetActor(this);
+ Resources.unwatchAllResources(this);
+ }
+
+ /**
+ * Return true if the given global is associated with this window global and should
+ * be added as a debuggee, false otherwise.
+ */
+ _shouldAddNewGlobalAsDebuggee(wrappedGlobal) {
+ // Otherwise, check if it is a WebExtension content script sandbox
+ const global = unwrapDebuggerObjectGlobal(wrappedGlobal);
+ if (!global) {
+ return false;
+ }
+
+ // Check if the global is a sdk page-mod sandbox.
+ let metadata = {};
+ let id = "";
+ try {
+ id = getInnerId(this.window);
+ metadata = Cu.getSandboxMetadata(global);
+ } catch (e) {
+ // ignore
+ }
+ if (metadata?.["inner-window-id"] && metadata["inner-window-id"] == id) {
+ return true;
+ }
+
+ return false;
+ }
+
+ _watchDocshells() {
+ // If for some unexpected reason, the actor is immediately destroyed,
+ // avoid registering leaking observer listener.
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ // In child processes, we watch all docshells living in the process.
+ Services.obs.addObserver(this, "webnavigation-create");
+ Services.obs.addObserver(this, "webnavigation-destroy");
+ this._docShellsObserved = true;
+
+ // We watch for all child docshells under the current document,
+ this._progressListener.watch(this.docShell);
+
+ // And list all already existing ones.
+ this._updateChildDocShells();
+ }
+
+ _unwatchDocshells() {
+ if (this._progressListener) {
+ this._progressListener.destroy();
+ this._progressListener = null;
+ this._originalWindow = null;
+ }
+
+ // Removes the observers being set in _watchDocshells, but only
+ // if _watchDocshells has been called. The target actor may be immediately destroyed
+ // and doesn't have time to register them.
+ // (Calling removeObserver without having called addObserver throws)
+ if (this._docShellsObserved) {
+ Services.obs.removeObserver(this, "webnavigation-create");
+ Services.obs.removeObserver(this, "webnavigation-destroy");
+ this._docShellsObserved = false;
+ }
+ }
+
+ _unwatchDocShell(docShell) {
+ if (this._progressListener) {
+ this._progressListener.unwatch(docShell);
+ }
+ }
+
+ switchToFrame(request) {
+ const windowId = request.windowId;
+ let win;
+
+ try {
+ win = Services.wm.getOuterWindowWithId(windowId);
+ } catch (e) {
+ // ignore
+ }
+ if (!win) {
+ throw {
+ error: "noWindow",
+ message: "The related docshell is destroyed or not found",
+ };
+ } else if (win == this.window) {
+ return {};
+ }
+
+ // Reply first before changing the document
+ DevToolsUtils.executeSoon(() => this._changeTopLevelDocument(win));
+
+ return {};
+ }
+
+ listFrames(request) {
+ const windows = this._docShellsToWindows(this.docShells);
+ return { frames: windows };
+ }
+
+ ensureWorkerDescriptorActorList() {
+ if (this._workerDescriptorActorList === null) {
+ this._workerDescriptorActorList = new WorkerDescriptorActorList(
+ this.conn,
+ {
+ type: Ci.nsIWorkerDebugger.TYPE_DEDICATED,
+ window: this.window,
+ }
+ );
+ }
+ return this._workerDescriptorActorList;
+ }
+
+ pauseWorkersUntilAttach(shouldPause) {
+ this.ensureWorkerDescriptorActorList().workerPauser.setPauseMatching(
+ shouldPause
+ );
+ }
+
+ listWorkers(request) {
+ return this.ensureWorkerDescriptorActorList()
+ .getList()
+ .then(actors => {
+ const pool = new Pool(this.conn, "worker-targets");
+ for (const actor of actors) {
+ pool.manage(actor);
+ }
+
+ // Do not destroy the pool before transfering ownership to the newly created
+ // pool, so that we do not accidently destroy actors that are still in use.
+ if (this._workerDescriptorActorPool) {
+ this._workerDescriptorActorPool.destroy();
+ }
+
+ this._workerDescriptorActorPool = pool;
+ this._workerDescriptorActorList.onListChanged =
+ this._onWorkerDescriptorActorListChanged;
+
+ return {
+ workers: actors,
+ };
+ });
+ }
+
+ logInPage(request) {
+ const { text, category, flags } = request;
+ const scriptErrorClass = Cc["@mozilla.org/scripterror;1"];
+ const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError);
+ scriptError.initWithWindowID(
+ text,
+ null,
+ null,
+ 0,
+ 0,
+ flags,
+ category,
+ getInnerId(this.window)
+ );
+ Services.console.logMessage(scriptError);
+ return {};
+ }
+
+ _onWorkerDescriptorActorListChanged() {
+ this._workerDescriptorActorList.onListChanged = null;
+ this.emit("workerListChanged");
+ }
+
+ _onConsoleApiProfilerEvent(subject, topic, data) {
+ // TODO: We will receive console-api-profiler events for any browser running
+ // in the same process as this target. We should filter irrelevant events,
+ // but console-api-profiler currently doesn't emit any information to identify
+ // the origin of the event. See Bug 1731033.
+
+ // The new performance panel is not compatible with console.profile().
+ const warningFlag = 1;
+ this.logInPage({
+ text:
+ "console.profile is not compatible with the new Performance recorder. " +
+ "See https://bugzilla.mozilla.org/show_bug.cgi?id=1730896",
+ category: "console.profile unavailable",
+ flags: warningFlag,
+ });
+ }
+
+ observe(subject, topic, data) {
+ // Ignore any event that comes before/after the actor is attached.
+ // That typically happens during Firefox shutdown.
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ subject.QueryInterface(Ci.nsIDocShell);
+
+ if (topic == "webnavigation-create") {
+ this._onDocShellCreated(subject);
+ } else if (topic == "webnavigation-destroy") {
+ this._onDocShellDestroy(subject);
+ }
+ }
+
+ _onDocShellCreated(docShell) {
+ // (chrome-)webnavigation-create is fired very early during docshell
+ // construction. In new root docshells within child processes, involving
+ // BrowserChild, this event is from within this call:
+ // https://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l912
+ // whereas the chromeEventHandler (and most likely other stuff) is set
+ // later:
+ // https://hg.mozilla.org/mozilla-central/annotate/74d7fb43bb44/dom/ipc/TabChild.cpp#l944
+ // So wait a tick before watching it:
+ DevToolsUtils.executeSoon(() => {
+ // Bug 1142752: sometimes, the docshell appears to be immediately
+ // destroyed, bailout early to prevent random exceptions.
+ if (docShell.isBeingDestroyed()) {
+ return;
+ }
+
+ // In child processes, we have new root docshells,
+ // let's watch them and all their child docshells.
+ if (this._isRootDocShell(docShell) && this.watchNewDocShells) {
+ this._progressListener.watch(docShell);
+ }
+ this._notifyDocShellsUpdate([docShell]);
+ });
+ }
+
+ _onDocShellDestroy(docShell) {
+ // Stop watching this docshell (the unwatch() method will check if we
+ // started watching it before).
+ this._unwatchDocShell(docShell);
+
+ const webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ this._notifyDocShellDestroy(webProgress);
+
+ if (webProgress.DOMWindow == this._originalWindow) {
+ // If the original top level document we connected to is removed,
+ // we try to switch to any other top level document
+ const rootDocShells = this.docShells.filter(d => {
+ // Ignore docshells without a working DOM Window.
+ // When we close firefox we have a chrome://extensions/content/dummy.xhtml
+ // which is in process of being destroyed and we might try to fallback to it.
+ // Unfortunately docshell.isBeingDestroyed() doesn't return true...
+ return d != this.docShell && this._isRootDocShell(d) && d.DOMWindow;
+ });
+ if (rootDocShells.length) {
+ const newRoot = rootDocShells[0];
+ this._originalWindow = newRoot.DOMWindow;
+ this._changeTopLevelDocument(this._originalWindow);
+ } else {
+ // If for some reason (typically during Firefox shutdown), the original
+ // document is destroyed, and there is no other top level docshell,
+ // we detach the actor to unregister all listeners and prevent any
+ // exception.
+ this.destroy();
+ }
+ return;
+ }
+
+ // If the currently targeted window global is destroyed, and we aren't on
+ // the top-level document, we have to switch to the top-level one.
+ if (
+ webProgress.DOMWindow == this.window &&
+ this.window != this._originalWindow
+ ) {
+ this._changeTopLevelDocument(this._originalWindow);
+ }
+ }
+
+ _isRootDocShell(docShell) {
+ // Should report as root docshell:
+ // - New top level window's docshells, when using ParentProcessTargetActor against a
+ // process. It allows tracking iframes of the newly opened windows
+ // like Browser console or new browser windows.
+ // - MozActivities or window.open frames on B2G, where a new root docshell
+ // is spawn in the child process of the app.
+ return !docShell.parent;
+ }
+
+ _docShellToWindow(docShell) {
+ const webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ const window = webProgress.DOMWindow;
+ const id = docShell.outerWindowID;
+ let parentID = undefined;
+ // Ignore the parent of the original document on non-e10s firefox,
+ // as we get the xul window as parent and don't care about it.
+ // Furthermore, ignore setting parentID when parent window is same as
+ // current window in order to deal with front end. e.g. toolbox will be fall
+ // into infinite loop due to recursive search with by using parent id.
+ if (
+ window.parent &&
+ window.parent != window &&
+ window != this._originalWindow
+ ) {
+ parentID = window.parent.docShell.outerWindowID;
+ }
+
+ return {
+ id,
+ parentID,
+ isTopLevel: window == this.originalWindow && this.isTopLevelTarget,
+ url: window.location.href,
+ title: window.document.title,
+ };
+ }
+
+ // Convert docShell list to windows objects list being sent to the client
+ _docShellsToWindows(docshells) {
+ return docshells
+ .filter(docShell => {
+ // Ensure docShell.document is available.
+ docShell.QueryInterface(Ci.nsIWebNavigation);
+
+ // don't include transient about:blank documents
+ if (docShell.document.isInitialDocument) {
+ return false;
+ }
+
+ return true;
+ })
+ .map(docShell => this._docShellToWindow(docShell));
+ }
+
+ _notifyDocShellsUpdate(docshells) {
+ // Only top level target uses frameUpdate in order to update the iframe dropdown.
+ // This may eventually be replaced by Target listening and target switching.
+ if (!this.isTopLevelTarget) {
+ return;
+ }
+
+ const windows = this._docShellsToWindows(docshells);
+
+ // Do not send the `frameUpdate` event if the windows array is empty.
+ if (!windows.length) {
+ return;
+ }
+
+ this.emit("frameUpdate", {
+ frames: windows,
+ });
+ }
+
+ _updateChildDocShells() {
+ this._notifyDocShellsUpdate(this.docShells);
+ }
+
+ _notifyDocShellDestroy(webProgress) {
+ // Only top level target uses frameUpdate in order to update the iframe dropdown.
+ // This may eventually be replaced by Target listening and target switching.
+ if (!this.isTopLevelTarget) {
+ return;
+ }
+
+ webProgress = webProgress.QueryInterface(Ci.nsIWebProgress);
+ const id = webProgress.DOMWindow.docShell.outerWindowID;
+ this.emit("frameUpdate", {
+ frames: [
+ {
+ id,
+ destroy: true,
+ },
+ ],
+ });
+ }
+
+ /**
+ * Creates and manages the thread actor as part of the Browsing Context Target pool.
+ * This sets up the content window for being debugged
+ */
+ _createThreadActor() {
+ this.threadActor = new ThreadActor(this, this.window);
+ this.manage(this.threadActor);
+ }
+
+ /**
+ * Exits the current thread actor and removes it from the Browsing Context Target pool.
+ * The content window is no longer being debugged after this call.
+ */
+ _destroyThreadActor() {
+ if (this.threadActor) {
+ this.threadActor.destroy();
+ this.threadActor = null;
+ }
+
+ if (this._sourcesManager) {
+ this._sourcesManager.destroy();
+ this._sourcesManager = null;
+ }
+ }
+
+ // Protocol Request Handlers
+
+ detach(request) {
+ // Destroy the actor in the next event loop in order
+ // to ensure responding to the `detach` request.
+ DevToolsUtils.executeSoon(() => {
+ this.destroy();
+ });
+
+ return {};
+ }
+
+ /**
+ * Bring the window global's window to front.
+ */
+ focus() {
+ if (this.window) {
+ this.window.focus();
+ }
+ return {};
+ }
+
+ goForward() {
+ // Wait a tick so that the response packet can be dispatched before the
+ // subsequent navigation event packet.
+ Services.tm.dispatchToMainThread(
+ DevToolsUtils.makeInfallible(() => {
+ // This won't work while the browser is shutting down and we don't really
+ // care.
+ if (Services.startup.shuttingDown) {
+ return;
+ }
+
+ this.webNavigation.goForward();
+ }, "WindowGlobalTargetActor.prototype.goForward's delayed body")
+ );
+
+ return {};
+ }
+
+ goBack() {
+ // Wait a tick so that the response packet can be dispatched before the
+ // subsequent navigation event packet.
+ Services.tm.dispatchToMainThread(
+ DevToolsUtils.makeInfallible(() => {
+ // This won't work while the browser is shutting down and we don't really
+ // care.
+ if (Services.startup.shuttingDown) {
+ return;
+ }
+
+ this.webNavigation.goBack();
+ }, "WindowGlobalTargetActor.prototype.goBack's delayed body")
+ );
+
+ return {};
+ }
+
+ /**
+ * Reload the page in this window global.
+ *
+ * @backward-compat { legacy }
+ * reload is preserved for third party tools. See Bug 1717837.
+ * DevTools should use Descriptor::reloadDescriptor instead.
+ */
+ reload(request) {
+ const force = request?.options?.force;
+ // Wait a tick so that the response packet can be dispatched before the
+ // subsequent navigation event packet.
+ Services.tm.dispatchToMainThread(
+ DevToolsUtils.makeInfallible(() => {
+ // This won't work while the browser is shutting down and we don't really
+ // care.
+ if (Services.startup.shuttingDown) {
+ return;
+ }
+
+ this.webNavigation.reload(
+ force
+ ? Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE
+ : Ci.nsIWebNavigation.LOAD_FLAGS_NONE
+ );
+ }, "WindowGlobalTargetActor.prototype.reload's delayed body")
+ );
+ return {};
+ }
+
+ /**
+ * Navigate this window global to a new location
+ */
+ navigateTo(request) {
+ // Wait a tick so that the response packet can be dispatched before the
+ // subsequent navigation event packet.
+ Services.tm.dispatchToMainThread(
+ DevToolsUtils.makeInfallible(() => {
+ this.window.location = request.url;
+ }, "WindowGlobalTargetActor.prototype.navigateTo's delayed body:" + request.url)
+ );
+ return {};
+ }
+
+ /**
+ * For browsing-context targets which can't use the watcher configuration
+ * actor (eg webextension targets), the client directly calls `reconfigure`.
+ * Once all targets support the watcher, this method can be removed.
+ */
+ reconfigure(request) {
+ const options = request.options || {};
+ return this.updateTargetConfiguration(options);
+ }
+
+ /**
+ * Apply target-specific options.
+ *
+ * This will be called by the watcher when the DevTools target-configuration
+ * is updated, or when a target is created via JSWindowActors.
+ */
+ updateTargetConfiguration(options = {}, calledFromDocumentCreation = false) {
+ if (!this.docShell) {
+ // The window global is already closed.
+ return;
+ }
+
+ // Also update configurations which applies to all target types
+ super.updateTargetConfiguration(options, calledFromDocumentCreation);
+
+ let reload = false;
+ if (typeof options.touchEventsOverride !== "undefined") {
+ const enableTouchSimulator = options.touchEventsOverride === "enabled";
+
+ this.docShell.metaViewportOverride = enableTouchSimulator
+ ? Ci.nsIDocShell.META_VIEWPORT_OVERRIDE_ENABLED
+ : Ci.nsIDocShell.META_VIEWPORT_OVERRIDE_NONE;
+
+ // We want to reload the document if it's an "existing" top level target on which
+ // the touch simulator will be toggled and the user has turned the
+ // "reload on touch simulation" setting on.
+ if (
+ enableTouchSimulator !== this.touchSimulator.enabled &&
+ options.reloadOnTouchSimulationToggle === true &&
+ this.isTopLevelTarget &&
+ !calledFromDocumentCreation
+ ) {
+ reload = true;
+ }
+
+ if (enableTouchSimulator) {
+ this.touchSimulator.start();
+ } else {
+ this.touchSimulator.stop();
+ }
+ }
+
+ if (typeof options.customFormatters !== "undefined") {
+ this.customFormatters = options.customFormatters;
+ }
+
+ if (typeof options.useSimpleHighlightersForReducedMotion == "boolean") {
+ this._useSimpleHighlightersForReducedMotion =
+ options.useSimpleHighlightersForReducedMotion;
+ this.emit("use-simple-highlighters-updated");
+ }
+
+ if (!this.isTopLevelTarget) {
+ // Following DevTools target options should only apply to the top target and be
+ // propagated through the window global tree via the platform.
+ return;
+ }
+ if (typeof options.restoreFocus == "boolean") {
+ this._restoreFocus = options.restoreFocus;
+ }
+ if (typeof options.recordAllocations == "object") {
+ const actor = this._memoryActor;
+ if (options.recordAllocations == null) {
+ actor.stopRecordingAllocations();
+ } else {
+ actor.attach();
+ actor.startRecordingAllocations(options.recordAllocations);
+ }
+ }
+
+ if (reload) {
+ this.webNavigation.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE);
+ }
+ }
+
+ get touchSimulator() {
+ if (!this._touchSimulator) {
+ this._touchSimulator = new TouchSimulator(this.chromeEventHandler);
+ }
+
+ return this._touchSimulator;
+ }
+
+ /**
+ * Opposite of the updateTargetConfiguration method, that resets document
+ * state when closing the toolbox.
+ */
+ _restoreTargetConfiguration() {
+ if (this._restoreFocus && this.browsingContext?.isActive) {
+ this.window.focus();
+ }
+ }
+
+ _changeTopLevelDocument(window) {
+ // In case of WebExtension, still using one WindowGlobalTarget instance for many document,
+ // when reloading the add-on we might not destroy the previous target and wait for the next
+ // one to come and destroy it.
+ if (this.window) {
+ // Fake a will-navigate on the previous document
+ // to let a chance to unregister it
+ this._willNavigate({
+ window: this.window,
+ newURI: window.location.href,
+ request: null,
+ isFrameSwitching: true,
+ navigationStart: Date.now(),
+ });
+
+ this._windowDestroyed(this.window, {
+ isFrozen: true,
+ isFrameSwitching: true,
+ });
+ }
+
+ // Immediately change the window as this window, if in process of unload
+ // may already be non working on the next cycle and start throwing
+ this._setWindow(window);
+
+ DevToolsUtils.executeSoon(() => {
+ // No need to do anything more if the actor is destroyed.
+ // e.g. the client has been closed and the actors destroyed in the meantime.
+ if (this.isDestroyed()) {
+ return;
+ }
+
+ // Then fake window-ready and navigate on the given document
+ this._windowReady(window, { isFrameSwitching: true });
+ DevToolsUtils.executeSoon(() => {
+ this._navigate(window, true);
+ });
+ });
+ }
+
+ _setWindow(window) {
+ // Here is the very important call where we switch the currently targeted
+ // window global (it will indirectly update this.window and many other
+ // attributes defined from docShell).
+ this.docShell = window.docShell;
+ this.emit("changed-toplevel-document");
+ this.emit("frameUpdate", {
+ selected: this.outerWindowID,
+ });
+ }
+
+ /**
+ * Handle location changes, by clearing the previous debuggees and enabling
+ * debugging, which may have been disabled temporarily by the
+ * DebuggerProgressListener.
+ */
+ _windowReady(window, { isFrameSwitching, isBFCache } = {}) {
+ if (this.ignoreSubFrames) {
+ return;
+ }
+ const isTopLevel = window == this.window;
+
+ // We just reset iframe list on WillNavigate, so we now list all existing
+ // frames when we load a new document in the original window
+ if (window == this._originalWindow && !isFrameSwitching) {
+ this._updateChildDocShells();
+ }
+
+ // If this follows WindowGlobal lifecycle, a new Target actor will be spawn for the top level
+ // target document. Only notify about in-process iframes.
+ // Note that OOP iframes won't emit window-ready and will also have their dedicated target.
+ // Also, we allow window-ready to be fired for iframe switching of top level documents,
+ // otherwise the iframe dropdown no longer works with server side targets.
+ if (this.followWindowGlobalLifeCycle && isTopLevel && !isFrameSwitching) {
+ return;
+ }
+
+ this.emit("window-ready", {
+ window,
+ isTopLevel,
+ isBFCache,
+ id: getWindowID(window),
+ isFrameSwitching,
+ });
+ }
+
+ _windowDestroyed(
+ window,
+ { id = null, isFrozen = false, isFrameSwitching = false }
+ ) {
+ if (this.ignoreSubFrames) {
+ return;
+ }
+ const isTopLevel = window == this.window;
+
+ // If this follows WindowGlobal lifecycle, this target will be destroyed, alongside its top level document.
+ // Only notify about in-process iframes.
+ // Note that OOP iframes won't emit window-ready and will also have their dedicated target.
+ // Also, we allow window-destroyed to be fired for iframe switching of top level documents,
+ // otherwise the iframe dropdown no longer works with server side targets.
+ if (this.followWindowGlobalLifeCycle && isTopLevel && !isFrameSwitching) {
+ return;
+ }
+
+ this.emit("window-destroyed", {
+ window,
+ isTopLevel,
+ id: id || getWindowID(window),
+ isFrozen,
+ });
+ }
+
+ /**
+ * Start notifying server and client about a new document being loaded in the
+ * currently targeted window global.
+ */
+ _willNavigate({
+ window,
+ newURI,
+ request,
+ isFrameSwitching = false,
+ navigationStart,
+ }) {
+ if (this.ignoreSubFrames) {
+ return;
+ }
+ let isTopLevel = window == this.window;
+
+ let reset = false;
+ if (window == this._originalWindow && !isFrameSwitching) {
+ // If the top level document changes and we are targeting an iframe, we
+ // need to reset to the upcoming new top level document. But for this
+ // will-navigate event, we will dispatch on the old window. (The inspector
+ // codebase expect to receive will-navigate for the currently displayed
+ // document in order to cleanup the markup view)
+ if (this.window != this._originalWindow) {
+ reset = true;
+ window = this.window;
+ isTopLevel = true;
+ }
+ }
+
+ // will-navigate event needs to be dispatched synchronously, by calling the
+ // listeners in the order or registration. This event fires once navigation
+ // starts, (all pending user prompts are dealt with), but before the first
+ // request starts.
+ this.emit("will-navigate", {
+ window,
+ isTopLevel,
+ newURI,
+ request,
+ navigationStart,
+ isFrameSwitching,
+ });
+
+ // We don't do anything for inner frames here.
+ // (we will only update thread actor on window-ready)
+ if (!isTopLevel) {
+ return;
+ }
+
+ // When the actor acts as a WindowGlobalTarget, will-navigate won't fired.
+ // Instead we will receive a new top level target with isTargetSwitching=true.
+ if (!this.followWindowGlobalLifeCycle) {
+ this.emit("tabNavigated", {
+ url: newURI,
+ state: "start",
+ isFrameSwitching,
+ });
+ }
+
+ if (reset) {
+ this._setWindow(this._originalWindow);
+ }
+ }
+
+ /**
+ * Notify server and client about a new document done loading in the current
+ * targeted window global.
+ */
+ _navigate(window, isFrameSwitching = false) {
+ if (this.ignoreSubFrames) {
+ return;
+ }
+ const isTopLevel = window == this.window;
+
+ // navigate event needs to be dispatched synchronously,
+ // by calling the listeners in the order or registration.
+ // This event is fired once the document is loaded,
+ // after the load event, it's document ready-state is 'complete'.
+ this.emit("navigate", {
+ window,
+ isTopLevel,
+ });
+
+ // We don't do anything for inner frames here.
+ // (we will only update thread actor on window-ready)
+ if (!isTopLevel) {
+ return;
+ }
+
+ // We may still significate when the document is done loading, via navigate.
+ // But as we no longer fire the "will-navigate", may be it is better to find
+ // other ways to get to our means.
+ // Listening to "navigate" is misleading as the document may already be loaded
+ // if we just opened the DevTools. So it is better to use "watch" pattern
+ // and instead have the actor either emit immediately resources as they are
+ // already available, or later on as the load progresses.
+ if (this.followWindowGlobalLifeCycle) {
+ return;
+ }
+
+ this.emit("tabNavigated", {
+ url: this.url,
+ title: this.title,
+ state: "stop",
+ isFrameSwitching,
+ });
+ }
+
+ removeActorByName(name) {
+ if (name in this._extraActors) {
+ const actor = this._extraActors[name];
+ if (this._targetScopedActorPool.has(actor)) {
+ this._targetScopedActorPool.removeActor(actor);
+ }
+ delete this._extraActors[name];
+ }
+ }
+}
+
+exports.WindowGlobalTargetActor = WindowGlobalTargetActor;
+
+class DebuggerProgressListener {
+ /**
+ * The DebuggerProgressListener class is an nsIWebProgressListener which
+ * handles onStateChange events for the targeted window global. If the user
+ * tries to navigate away from a paused page, the listener makes sure that the
+ * debuggee is resumed before the navigation begins.
+ *
+ * @param WindowGlobalTargetActor targetActor
+ * The window global target actor associated with this listener.
+ */
+ constructor(targetActor) {
+ this._targetActor = targetActor;
+ this._onWindowCreated = this.onWindowCreated.bind(this);
+ this._onWindowHidden = this.onWindowHidden.bind(this);
+
+ // Watch for windows destroyed (global observer that will need filtering)
+ Services.obs.addObserver(this, "inner-window-destroyed");
+
+ // XXX: for now we maintain the list of windows we know about in this instance
+ // so that we can discriminate windows we care about when observing
+ // inner-window-destroyed events. Bug 1016952 would remove the need for this.
+ this._knownWindowIDs = new Map();
+
+ this._watchedDocShells = new WeakSet();
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]);
+
+ destroy() {
+ Services.obs.removeObserver(this, "inner-window-destroyed");
+ this._knownWindowIDs.clear();
+ this._knownWindowIDs = null;
+ }
+
+ watch(docShell) {
+ // Add the docshell to the watched set. We're actually adding the window,
+ // because docShell objects are not wrappercached and would be rejected
+ // by the WeakSet.
+ const docShellWindow = docShell.domWindow;
+ this._watchedDocShells.add(docShellWindow);
+
+ const webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+
+ const handler = getDocShellChromeEventHandler(docShell);
+ handler.addEventListener("DOMWindowCreated", this._onWindowCreated, true);
+ handler.addEventListener("pageshow", this._onWindowCreated, true);
+ handler.addEventListener("pagehide", this._onWindowHidden, true);
+
+ // Dispatch the _windowReady event on the targetActor for pre-existing windows
+ const windows = this._targetActor.ignoreSubFrames
+ ? [docShellWindow]
+ : this._getWindowsInDocShell(docShell);
+ for (const win of windows) {
+ this._targetActor._windowReady(win);
+ this._knownWindowIDs.set(getWindowID(win), win);
+ }
+
+ // The `watchedByDevTools` enables gecko behavior tied to this flag, such as:
+ // - reporting the contents of HTML loaded in the docshells,
+ // - or capturing stacks for the network monitor.
+ //
+ // This flag is also set in frame-helper but in the case of the browser toolbox, we
+ // don't have the watcher enabled by default yet, and as a result we need to set it
+ // here for the parent process window global.
+ // This should be removed as part of Bug 1709529.
+ if (this._targetActor.typeName === "parentProcessTarget") {
+ docShell.browsingContext.watchedByDevTools = true;
+ }
+ // Immediately enable CSS error reports on new top level docshells, if this was already enabled.
+ // This is specific to MBT and WebExtension targets (so the isRootActor check).
+ if (
+ this._targetActor.isRootActor &&
+ this._targetActor.docShell.cssErrorReportingEnabled
+ ) {
+ docShell.cssErrorReportingEnabled = true;
+ }
+ }
+
+ unwatch(docShell) {
+ const docShellWindow = docShell.domWindow;
+ if (!this._watchedDocShells.has(docShellWindow)) {
+ return;
+ }
+ this._watchedDocShells.delete(docShellWindow);
+
+ const webProgress = docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ // During process shutdown, the docshell may already be cleaned up and throw
+ try {
+ webProgress.removeProgressListener(this);
+ } catch (e) {
+ // ignore
+ }
+
+ const handler = getDocShellChromeEventHandler(docShell);
+ handler.removeEventListener(
+ "DOMWindowCreated",
+ this._onWindowCreated,
+ true
+ );
+ handler.removeEventListener("pageshow", this._onWindowCreated, true);
+ handler.removeEventListener("pagehide", this._onWindowHidden, true);
+
+ const windows = this._targetActor.ignoreSubFrames
+ ? [docShellWindow]
+ : this._getWindowsInDocShell(docShell);
+ for (const win of windows) {
+ this._knownWindowIDs.delete(getWindowID(win));
+ }
+
+ // We only reset it for parent process target actor as the flag should be set in parent
+ // process, and thus is set elsewhere for other type of BrowsingContextActor.
+ if (this._targetActor.typeName === "parentProcessTarget") {
+ docShell.browsingContext.watchedByDevTools = false;
+ }
+ }
+
+ _getWindowsInDocShell(docShell) {
+ return getChildDocShells(docShell).map(d => {
+ return d.domWindow;
+ });
+ }
+
+ onWindowCreated = DevToolsUtils.makeInfallible(function (evt) {
+ if (this._targetActor.isDestroyed()) {
+ return;
+ }
+
+ // If we're in a frame swap (which occurs when toggling RDM, for example), then we can
+ // ignore this event, as the window never really went anywhere for our purposes.
+ if (evt.inFrameSwap) {
+ return;
+ }
+
+ const window = evt.target.defaultView;
+ if (!window) {
+ // Some old UIs might emit unrelated events called pageshow/pagehide on
+ // elements which are not documents. Bail in this case. See Bug 1669666.
+ return;
+ }
+
+ const innerID = getWindowID(window);
+
+ // This handler is called for two events: "DOMWindowCreated" and "pageshow".
+ // Bail out if we already processed this window.
+ if (this._knownWindowIDs.has(innerID)) {
+ return;
+ }
+ this._knownWindowIDs.set(innerID, window);
+
+ // For a regular page navigation, "DOMWindowCreated" is fired before
+ // "pageshow". If the current event is "pageshow" but we have not processed
+ // the window yet, it means this is a BF cache navigation. In theory,
+ // `event.persisted` should be set for BF cache navigation events, but it is
+ // not always available, so we fallback on checking if "pageshow" is the
+ // first event received for a given window (see Bug 1378133).
+ const isBFCache = evt.type == "pageshow";
+
+ this._targetActor._windowReady(window, { isBFCache });
+ }, "DebuggerProgressListener.prototype.onWindowCreated");
+
+ onWindowHidden = DevToolsUtils.makeInfallible(function (evt) {
+ if (this._targetActor.isDestroyed()) {
+ return;
+ }
+
+ // If we're in a frame swap (which occurs when toggling RDM, for example), then we can
+ // ignore this event, as the window isn't really going anywhere for our purposes.
+ if (evt.inFrameSwap) {
+ return;
+ }
+
+ // Only act as if the window has been destroyed if the 'pagehide' event
+ // was sent for a persisted window (persisted is set when the page is put
+ // and frozen in the bfcache). If the page isn't persisted, the observer's
+ // inner-window-destroyed event will handle it.
+ if (!evt.persisted) {
+ return;
+ }
+
+ const window = evt.target.defaultView;
+ if (!window) {
+ // Some old UIs might emit unrelated events called pageshow/pagehide on
+ // elements which are not documents. Bail in this case. See Bug 1669666.
+ return;
+ }
+
+ this._targetActor._windowDestroyed(window, { isFrozen: true });
+ this._knownWindowIDs.delete(getWindowID(window));
+ }, "DebuggerProgressListener.prototype.onWindowHidden");
+
+ observe = DevToolsUtils.makeInfallible(function (subject, topic) {
+ if (this._targetActor.isDestroyed()) {
+ return;
+ }
+
+ // Because this observer will be called for all inner-window-destroyed in
+ // the application, we need to filter out events for windows we are not
+ // watching
+ const innerID = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
+ const window = this._knownWindowIDs.get(innerID);
+ if (window) {
+ this._knownWindowIDs.delete(innerID);
+ this._targetActor._windowDestroyed(window, { id: innerID });
+ }
+
+ // Bug 1598364: when debugging browser.xhtml from the Browser Toolbox
+ // the DOMWindowCreated/pageshow/pagehide event listeners have to be
+ // re-registered against the next document when we reload browser.html
+ // (or navigate to another doc).
+ // That's because we registered the listener on docShell.domWindow as
+ // top level windows don't have a chromeEventHandler.
+ if (
+ this._watchedDocShells.has(window) &&
+ !window.docShell.chromeEventHandler
+ ) {
+ // First cleanup all the existing listeners
+ this.unwatch(window.docShell);
+ // Re-register new ones. The docShell is already referencing the new document.
+ this.watch(window.docShell);
+ }
+ }, "DebuggerProgressListener.prototype.observe");
+
+ onStateChange = DevToolsUtils.makeInfallible(function (
+ progress,
+ request,
+ flag,
+ status
+ ) {
+ if (this._targetActor.isDestroyed()) {
+ return;
+ }
+ progress.QueryInterface(Ci.nsIDocShell);
+ if (progress.isBeingDestroyed()) {
+ return;
+ }
+
+ const isStart = flag & Ci.nsIWebProgressListener.STATE_START;
+ const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP;
+ const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
+ const isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW;
+
+ // Ideally, we would fetch navigationStart from window.performance.timing.navigationStart
+ // but as WindowGlobal isn't instantiated yet we don't have access to it.
+ // This is ultimately handed over to DocumentEventListener, which uses this.
+ // See its comment about WILL_NAVIGATE_TIME_SHIFT for more details about the related workaround.
+ const navigationStart = Date.now();
+
+ // Catch any iframe location change
+ if (isDocument && isStop) {
+ // Watch document stop to ensure having the new iframe url.
+ this._targetActor._notifyDocShellsUpdate([progress]);
+ }
+
+ const window = progress.DOMWindow;
+ if (isDocument && isStart) {
+ // One of the earliest events that tells us a new URI
+ // is being loaded in this window.
+ const newURI = request instanceof Ci.nsIChannel ? request.URI.spec : null;
+ this._targetActor._willNavigate({
+ window,
+ newURI,
+ request,
+ isFrameSwitching: false,
+ navigationStart,
+ });
+ }
+ if (isWindow && isStop) {
+ // Don't dispatch "navigate" event just yet when there is a redirect to
+ // about:neterror page.
+ // Navigating to about:neterror will make `status` be something else than NS_OK.
+ // But for some error like NS_BINDING_ABORTED we don't want to emit any `navigate`
+ // event as the page load has been cancelled and the related page document is going
+ // to be a dead wrapper.
+ if (
+ request.status != Cr.NS_OK &&
+ request.status != Cr.NS_BINDING_ABORTED
+ ) {
+ // Instead, listen for DOMContentLoaded as about:neterror is loaded
+ // with LOAD_BACKGROUND flags and never dispatches load event.
+ // That may be the same reason why there is no onStateChange event
+ // for about:neterror loads.
+ const handler = getDocShellChromeEventHandler(progress);
+ const onLoad = evt => {
+ // Ignore events from iframes
+ if (evt.target === window.document) {
+ handler.removeEventListener("DOMContentLoaded", onLoad, true);
+ this._targetActor._navigate(window);
+ }
+ };
+ handler.addEventListener("DOMContentLoaded", onLoad, true);
+ } else {
+ // Somewhat equivalent of load event.
+ // (window.document.readyState == complete)
+ this._targetActor._navigate(window);
+ }
+ }
+ },
+ "DebuggerProgressListener.prototype.onStateChange");
+}
diff --git a/devtools/server/actors/targets/worker.js b/devtools/server/actors/targets/worker.js
new file mode 100644
index 0000000000..cf5f7b83c9
--- /dev/null
+++ b/devtools/server/actors/targets/worker.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 {
+ workerTargetSpec,
+} = require("resource://devtools/shared/specs/targets/worker.js");
+
+const {
+ WebConsoleActor,
+} = require("resource://devtools/server/actors/webconsole.js");
+const { ThreadActor } = require("resource://devtools/server/actors/thread.js");
+const { TracerActor } = require("resource://devtools/server/actors/tracer.js");
+const {
+ ObjectsManagerActor,
+} = require("resource://devtools/server/actors/objects-manager.js");
+
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const makeDebuggerUtil = require("resource://devtools/server/actors/utils/make-debugger.js");
+const {
+ SourcesManager,
+} = require("resource://devtools/server/actors/utils/sources-manager.js");
+
+const {
+ BaseTargetActor,
+} = require("resource://devtools/server/actors/targets/base-target-actor.js");
+
+class WorkerTargetActor extends BaseTargetActor {
+ /**
+ * Target actor for a worker in the content process.
+ *
+ * @param {DevToolsServerConnection} conn: The connection to the client.
+ * @param {WorkerGlobalScope} workerGlobal: The worker global.
+ * @param {Object} workerDebuggerData: The worker debugger information
+ * @param {String} workerDebuggerData.id: The worker debugger id
+ * @param {String} workerDebuggerData.url: The worker debugger url
+ * @param {String} workerDebuggerData.type: The worker debugger type
+ * @param {Boolean} workerDebuggerData.workerConsoleApiMessagesDispatchedToMainThread:
+ * Value of the dom.worker.console.dispatch_events_to_main_thread pref
+ * @param {Object} sessionContext: The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ */
+ constructor(conn, workerGlobal, workerDebuggerData, sessionContext) {
+ super(conn, Targets.TYPES.WORKER, workerTargetSpec);
+
+ // workerGlobal is needed by the console actor for evaluations.
+ this.workerGlobal = workerGlobal;
+ this.sessionContext = sessionContext;
+
+ // We don't have access to Ci from worker thread
+ // 2 == nsIWorkerDebugger.TYPE_SERVICE
+ // 1 == nsIWorkerDebugger.TYPE_SHARED
+ if (workerDebuggerData.type == 2) {
+ this.targetType = Targets.TYPES.SERVICE_WORKER;
+ } else if (workerDebuggerData.type == 1) {
+ this.targetType = Targets.TYPES.SHARED_WORKER;
+ }
+
+ this._workerDebuggerData = workerDebuggerData;
+ this._sourcesManager = null;
+ this.workerConsoleApiMessagesDispatchedToMainThread =
+ workerDebuggerData.workerConsoleApiMessagesDispatchedToMainThread;
+
+ this.makeDebugger = makeDebuggerUtil.bind(null, {
+ findDebuggees: () => {
+ return [workerGlobal];
+ },
+ shouldAddNewGlobalAsDebuggee: () => true,
+ });
+
+ // needed by the console actor
+ this.threadActor = new ThreadActor(this, this.workerGlobal);
+
+ // needed by the thread actor to communicate with the console when evaluating logpoints.
+ this._consoleActor = new WebConsoleActor(this.conn, this);
+
+ this.tracerActor = new TracerActor(this.conn, this);
+ this.objectsManagerActor = new ObjectsManagerActor(this.conn, this);
+
+ this.manage(this.threadActor);
+ this.manage(this._consoleActor);
+ this.manage(this.tracerActor);
+ this.manage(this.objectsManagerActor);
+ }
+
+ // Expose the worker URL to the thread actor.
+ // so that it can easily know what is the base URL of all worker scripts.
+ get workerUrl() {
+ return this._workerDebuggerData.url;
+ }
+
+ form() {
+ return {
+ actor: this.actorID,
+
+ consoleActor: this._consoleActor?.actorID,
+ threadActor: this.threadActor?.actorID,
+ tracerActor: this.tracerActor?.actorID,
+ objectsManagerActor: this.objectsManagerActor?.actorID,
+
+ id: this._workerDebuggerData.id,
+ type: this._workerDebuggerData.type,
+ url: this._workerDebuggerData.url,
+ traits: {
+ // See trait description in browsing-context.js
+ supportsTopLevelTargetFlag: false,
+ },
+ };
+ }
+
+ get dbg() {
+ if (!this._dbg) {
+ this._dbg = this.makeDebugger();
+ }
+ return this._dbg;
+ }
+
+ get sourcesManager() {
+ if (this._sourcesManager === null) {
+ this._sourcesManager = new SourcesManager(this.threadActor);
+ }
+
+ return this._sourcesManager;
+ }
+
+ // This is called from the ThreadActor#onAttach method
+ onThreadAttached() {
+ // This isn't an RDP event and is only listened to from startup/worker.js.
+ this.emit("worker-thread-attached");
+ }
+
+ destroy() {
+ super.destroy();
+
+ if (this._sourcesManager) {
+ this._sourcesManager.destroy();
+ this._sourcesManager = null;
+ }
+
+ this.workerGlobal = null;
+ this._dbg = null;
+ this._consoleActor = null;
+ this.threadActor = null;
+ }
+}
+exports.WorkerTargetActor = WorkerTargetActor;
diff --git a/devtools/server/actors/thread-configuration.js b/devtools/server/actors/thread-configuration.js
new file mode 100644
index 0000000000..8cd28f5887
--- /dev/null
+++ b/devtools/server/actors/thread-configuration.js
@@ -0,0 +1,80 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ threadConfigurationSpec,
+} = require("resource://devtools/shared/specs/thread-configuration.js");
+
+const {
+ SessionDataHelpers,
+} = require("resource://devtools/server/actors/watcher/SessionDataHelpers.jsm");
+const {
+ SUPPORTED_DATA: { THREAD_CONFIGURATION },
+} = SessionDataHelpers;
+
+// List of options supported by this thread configuration actor.
+const SUPPORTED_OPTIONS = {
+ // Controls pausing on debugger statement.
+ // (This is enabled by default if omitted)
+ shouldPauseOnDebuggerStatement: true,
+ // Enable pausing on exceptions.
+ pauseOnExceptions: true,
+ // Disable pausing on caught exceptions.
+ ignoreCaughtExceptions: true,
+ // Include previously saved stack frames when paused.
+ shouldIncludeSavedFrames: true,
+ // Include async stack frames when paused.
+ shouldIncludeAsyncLiveFrames: true,
+ // Stop pausing on breakpoints.
+ skipBreakpoints: true,
+ // Log the event break points.
+ logEventBreakpoints: true,
+ // Enable debugging asm & wasm.
+ // See https://searchfox.org/mozilla-central/source/js/src/doc/Debugger/Debugger.md#16-26
+ observeAsmJS: true,
+ observeWasm: true,
+ // Should pause all the workers untill thread has attached.
+ pauseWorkersUntilAttach: true,
+};
+
+/**
+ * This actor manages the configuration options which apply to thread actor for all the targets.
+ *
+ * Configuration options should be applied to all concerned targets when the
+ * configuration is updated, and new targets should also be able to read the
+ * flags when they are created. The flags will be forwarded to the WatcherActor
+ * and stored as THREAD_CONFIGURATION data entries.
+ *
+ * @constructor
+ *
+ */
+class ThreadConfigurationActor extends Actor {
+ constructor(watcherActor) {
+ super(watcherActor.conn, threadConfigurationSpec);
+ this.watcherActor = watcherActor;
+ }
+
+ async updateConfiguration(configuration) {
+ const configArray = Object.keys(configuration)
+ .filter(key => {
+ if (!SUPPORTED_OPTIONS[key]) {
+ console.warn(`Unsupported option for ThreadConfiguration: ${key}`);
+ return false;
+ }
+ return true;
+ })
+ .map(key => ({ key, value: configuration[key] }));
+
+ await this.watcherActor.addOrSetDataEntry(
+ THREAD_CONFIGURATION,
+ configArray,
+ "add"
+ );
+ }
+}
+
+exports.ThreadConfigurationActor = ThreadConfigurationActor;
diff --git a/devtools/server/actors/thread.js b/devtools/server/actors/thread.js
new file mode 100644
index 0000000000..d33b3e5eb2
--- /dev/null
+++ b/devtools/server/actors/thread.js
@@ -0,0 +1,2385 @@
+/* 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";
+
+// protocol.js uses objects as exceptions in order to define
+// error packets.
+/* eslint-disable no-throw-literal */
+
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+const { Pool } = require("resource://devtools/shared/protocol/Pool.js");
+const { threadSpec } = require("resource://devtools/shared/specs/thread.js");
+
+const {
+ createValueGrip,
+} = require("resource://devtools/server/actors/object/utils.js");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const Debugger = require("Debugger");
+const { assert, dumpn, reportException } = DevToolsUtils;
+const {
+ getAvailableEventBreakpoints,
+ eventBreakpointForNotification,
+ eventsRequireNotifications,
+ firstStatementBreakpointId,
+ makeEventBreakpointMessage,
+} = require("resource://devtools/server/actors/utils/event-breakpoints.js");
+const {
+ WatchpointMap,
+} = require("resource://devtools/server/actors/utils/watchpoint-map.js");
+
+const {
+ logEvent,
+} = require("resource://devtools/server/actors/utils/logEvent.js");
+
+loader.lazyRequireGetter(
+ this,
+ "EnvironmentActor",
+ "resource://devtools/server/actors/environment.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "BreakpointActorMap",
+ "resource://devtools/server/actors/utils/breakpoint-actor-map.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PauseScopedObjectActor",
+ "resource://devtools/server/actors/pause-scoped.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EventLoop",
+ "resource://devtools/server/actors/utils/event-loop.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["FrameActor", "getSavedFrameParent", "isValidSavedFrame"],
+ "resource://devtools/server/actors/frame.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "HighlighterEnvironment",
+ "resource://devtools/server/actors/highlighters.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "PausedDebuggerOverlay",
+ "resource://devtools/server/actors/highlighters/paused-debugger.js",
+ true
+);
+
+const PROMISE_REACTIONS = new WeakMap();
+function cacheReactionsForFrame(frame) {
+ if (frame.asyncPromise) {
+ const reactions = frame.asyncPromise.getPromiseReactions();
+ const existingReactions = PROMISE_REACTIONS.get(frame.asyncPromise);
+ if (
+ reactions.length &&
+ (!existingReactions || reactions.length > existingReactions.length)
+ ) {
+ PROMISE_REACTIONS.set(frame.asyncPromise, reactions);
+ }
+ }
+}
+
+function createStepForReactionTracking(onStep) {
+ return function () {
+ cacheReactionsForFrame(this);
+ return onStep ? onStep.apply(this, arguments) : undefined;
+ };
+}
+
+const getAsyncParentFrame = frame => {
+ if (!frame.asyncPromise) {
+ return null;
+ }
+
+ // We support returning Frame actors for frames that are suspended
+ // at an 'await', and here we want to walk upward to look for the first
+ // frame that will be resumed when the current frame's promise resolves.
+ let reactions =
+ PROMISE_REACTIONS.get(frame.asyncPromise) ||
+ frame.asyncPromise.getPromiseReactions();
+
+ while (true) {
+ // We loop here because we may have code like:
+ //
+ // async function inner(){ debugger; }
+ //
+ // async function outer() {
+ // await Promise.resolve().then(() => inner());
+ // }
+ //
+ // where we can see that when `inner` resolves, we will resume from
+ // `outer`, even though there is a layer of promises between, and
+ // that layer could be any number of promises deep.
+ if (!(reactions[0] instanceof Debugger.Object)) {
+ break;
+ }
+
+ reactions = reactions[0].getPromiseReactions();
+ }
+
+ if (reactions[0] instanceof Debugger.Frame) {
+ return reactions[0];
+ }
+ return null;
+};
+const RESTARTED_FRAMES = new WeakSet();
+
+// Thread actor possible states:
+const STATES = {
+ // Before ThreadActor.attach is called:
+ DETACHED: "detached",
+ // After the actor is destroyed:
+ EXITED: "exited",
+
+ // States possible in between DETACHED AND EXITED:
+ // Default state, when the thread isn't paused,
+ RUNNING: "running",
+ // When paused on any type of breakpoint, or, when the client requested an interrupt.
+ PAUSED: "paused",
+};
+exports.STATES = STATES;
+
+// Possible values for the `why.type` attribute in "paused" event
+const PAUSE_REASONS = {
+ ALREADY_PAUSED: "alreadyPaused",
+ INTERRUPTED: "interrupted", // Associated with why.onNext attribute
+ MUTATION_BREAKPOINT: "mutationBreakpoint", // Associated with why.mutationType and why.message attributes
+ DEBUGGER_STATEMENT: "debuggerStatement",
+ EXCEPTION: "exception",
+ XHR: "XHR",
+ EVENT_BREAKPOINT: "eventBreakpoint",
+ RESUME_LIMIT: "resumeLimit",
+};
+exports.PAUSE_REASONS = PAUSE_REASONS;
+
+class ThreadActor extends Actor {
+ /**
+ * Creates a ThreadActor.
+ *
+ * ThreadActors manage execution/inspection of debuggees.
+ *
+ * @param parent TargetActor
+ * This |ThreadActor|'s parent actor. i.e. one of the many Target actors.
+ * @param aGlobal object [optional]
+ * An optional (for content debugging only) reference to the content
+ * window.
+ */
+ constructor(parent, global) {
+ super(parent.conn, threadSpec);
+
+ this._state = STATES.DETACHED;
+ this._parent = parent;
+ this.global = global;
+ this._options = {
+ skipBreakpoints: false,
+ };
+ this._gripDepth = 0;
+ this._parentClosed = false;
+ this._observingNetwork = false;
+ this._frameActors = [];
+ this._xhrBreakpoints = [];
+
+ this._dbg = null;
+ this._threadLifetimePool = null;
+ this._activeEventPause = null;
+ this._pauseOverlay = null;
+ this._priorPause = null;
+
+ this._activeEventBreakpoints = new Set();
+ this._frameActorMap = new WeakMap();
+ this._debuggerSourcesSeen = new WeakSet();
+
+ // A Set of URLs string to watch for when new sources are found by
+ // the debugger instance.
+ this._onLoadBreakpointURLs = new Set();
+
+ // A WeakMap from Debugger.Frame to an exception value which will be ignored
+ // when deciding to pause if the value is thrown by the frame. When we are
+ // pausing on exceptions then we only want to pause when the youngest frame
+ // throws a particular exception, instead of for all older frames as well.
+ this._handledFrameExceptions = new WeakMap();
+
+ this._watchpointsMap = new WatchpointMap(this);
+
+ this.breakpointActorMap = new BreakpointActorMap(this);
+
+ this._nestedEventLoop = new EventLoop({
+ thread: this,
+ });
+
+ this.onNewSourceEvent = this.onNewSourceEvent.bind(this);
+
+ this.createCompletionGrip = this.createCompletionGrip.bind(this);
+ this.onDebuggerStatement = this.onDebuggerStatement.bind(this);
+ this.onNewScript = this.onNewScript.bind(this);
+ this.objectGrip = this.objectGrip.bind(this);
+ this.pauseObjectGrip = this.pauseObjectGrip.bind(this);
+ this._onOpeningRequest = this._onOpeningRequest.bind(this);
+ this._onNewDebuggee = this._onNewDebuggee.bind(this);
+ this._onExceptionUnwind = this._onExceptionUnwind.bind(this);
+ this._eventBreakpointListener = this._eventBreakpointListener.bind(this);
+ this._onWindowReady = this._onWindowReady.bind(this);
+ this._onWillNavigate = this._onWillNavigate.bind(this);
+ this._onNavigate = this._onNavigate.bind(this);
+
+ this._parent.on("window-ready", this._onWindowReady);
+ this._parent.on("will-navigate", this._onWillNavigate);
+ this._parent.on("navigate", this._onNavigate);
+
+ this._firstStatementBreakpoint = null;
+ this._debuggerNotificationObserver = new DebuggerNotificationObserver();
+ }
+
+ // Used by the ObjectActor to keep track of the depth of grip() calls.
+ _gripDepth = null;
+
+ get dbg() {
+ if (!this._dbg) {
+ this._dbg = this._parent.dbg;
+ // Keep the debugger disabled until a client attaches.
+ if (this._state === STATES.DETACHED) {
+ this._dbg.disable();
+ } else {
+ this._dbg.enable();
+ }
+ }
+ return this._dbg;
+ }
+
+ // Current state of the thread actor:
+ // - detached: state, before ThreadActor.attach is called,
+ // - exited: state, after the actor is destroyed,
+ // States possible in between these two states:
+ // - running: default state, when the thread isn't paused,
+ // - paused: state, when paused on any type of breakpoint, or, when the client requested an interrupt.
+ get state() {
+ return this._state;
+ }
+
+ // XXX: soon to be equivalent to !isDestroyed once the thread actor is initialized on target creation.
+ get attached() {
+ return this.state == STATES.RUNNING || this.state == STATES.PAUSED;
+ }
+
+ get threadLifetimePool() {
+ if (!this._threadLifetimePool) {
+ this._threadLifetimePool = new Pool(this.conn, "thread");
+ this._threadLifetimePool.objectActors = new WeakMap();
+ }
+ return this._threadLifetimePool;
+ }
+
+ getThreadLifetimeObject(raw) {
+ return this.threadLifetimePool.objectActors.get(raw);
+ }
+
+ createValueGrip(value) {
+ return createValueGrip(value, this.threadLifetimePool, this.objectGrip);
+ }
+
+ get sourcesManager() {
+ return this._parent.sourcesManager;
+ }
+
+ get breakpoints() {
+ return this._parent.breakpoints;
+ }
+
+ get youngestFrame() {
+ if (this.state != STATES.PAUSED) {
+ return null;
+ }
+ return this.dbg.getNewestFrame();
+ }
+
+ get shouldSkipAnyBreakpoint() {
+ return (
+ // Disable all types of breakpoints if:
+ // - the user explicitly requested it via the option
+ this._options.skipBreakpoints ||
+ // - or when we are evaluating some javascript via the console actor and disableBreaks
+ // has been set to true (which happens for most evaluating except the console input)
+ this.insideClientEvaluation?.disableBreaks
+ );
+ }
+
+ isPaused() {
+ return this._state === STATES.PAUSED;
+ }
+
+ lastPausedPacket() {
+ return this._priorPause;
+ }
+
+ /**
+ * Remove all debuggees and clear out the thread's sources.
+ */
+ clearDebuggees() {
+ if (this._dbg) {
+ this.dbg.removeAllDebuggees();
+ }
+ }
+
+ /**
+ * Destroy the debugger and put the actor in the exited state.
+ *
+ * As part of destroy, we: clean up listeners, debuggees and
+ * clear actor pools associated with the lifetime of this actor.
+ */
+ destroy() {
+ dumpn("in ThreadActor.prototype.destroy");
+ if (this._state == STATES.PAUSED) {
+ this.doResume();
+ }
+
+ this.removeAllWatchpoints();
+ this._xhrBreakpoints = [];
+ this._updateNetworkObserver();
+
+ this._activeEventBreakpoints = new Set();
+ this._debuggerNotificationObserver.removeListener(
+ this._eventBreakpointListener
+ );
+
+ for (const global of this.dbg.getDebuggees()) {
+ try {
+ this._debuggerNotificationObserver.disconnect(
+ global.unsafeDereference()
+ );
+ } catch (e) {}
+ }
+
+ this._parent.off("window-ready", this._onWindowReady);
+ this._parent.off("will-navigate", this._onWillNavigate);
+ this._parent.off("navigate", this._onNavigate);
+
+ this.sourcesManager.off("newSource", this.onNewSourceEvent);
+ this.clearDebuggees();
+ this._threadLifetimePool.destroy();
+ this._threadLifetimePool = null;
+ this._dbg = null;
+ this._state = STATES.EXITED;
+
+ super.destroy();
+ }
+
+ /**
+ * Tells if the thread actor has been initialized/attached on target creation
+ * by the server codebase. (And not late, from the frontend, by the TargetMixinFront class)
+ */
+ isAttached() {
+ return !!this.alreadyAttached;
+ }
+
+ // Request handlers
+ attach(options) {
+ // Note that the client avoids trying to call attach if already attached.
+ // But just in case, avoid any possible duplicate call to attach.
+ if (this.alreadyAttached) {
+ return;
+ }
+
+ if (this.state === STATES.EXITED) {
+ throw {
+ error: "exited",
+ message: "threadActor has exited",
+ };
+ }
+
+ if (this.state !== STATES.DETACHED) {
+ throw {
+ error: "wrongState",
+ message: "Current state is " + this.state,
+ };
+ }
+
+ this.dbg.onDebuggerStatement = this.onDebuggerStatement;
+ this.dbg.onNewScript = this.onNewScript;
+ this.dbg.onNewDebuggee = this._onNewDebuggee;
+
+ this.sourcesManager.on("newSource", this.onNewSourceEvent);
+
+ this.reconfigure(options);
+
+ // Switch state from DETACHED to RUNNING
+ this._state = STATES.RUNNING;
+
+ this.alreadyAttached = true;
+ this.dbg.enable();
+
+ // Notify the parent that we've finished attaching. If this is a worker
+ // thread which was paused until attaching, this will allow content to
+ // begin executing.
+ if (this._parent.onThreadAttached) {
+ this._parent.onThreadAttached();
+ }
+ if (Services.obs) {
+ // Set a wrappedJSObject property so |this| can be sent via the observer service
+ // for the xpcshell harness.
+ this.wrappedJSObject = this;
+ Services.obs.notifyObservers(this, "devtools-thread-ready");
+ }
+ }
+
+ toggleEventLogging(logEventBreakpoints) {
+ this._options.logEventBreakpoints = logEventBreakpoints;
+ return this._options.logEventBreakpoints;
+ }
+
+ get pauseOverlay() {
+ if (this._pauseOverlay) {
+ return this._pauseOverlay;
+ }
+
+ const env = new HighlighterEnvironment();
+ env.initFromTargetActor(this._parent);
+ const highlighter = new PausedDebuggerOverlay(env, {
+ resume: () => this.resume(null),
+ stepOver: () => this.resume({ type: "next" }),
+ });
+ this._pauseOverlay = highlighter;
+ return highlighter;
+ }
+
+ _canShowOverlay() {
+ const { window } = this._parent;
+
+ // The CanvasFrameAnonymousContentHelper class we're using to create the paused overlay
+ // need to have access to a documentElement.
+ // We might have access to a non-chrome window getter that is a Sandox (e.g. in the
+ // case of ContentProcessTargetActor).
+ if (!window?.document?.documentElement) {
+ return false;
+ }
+
+ // Ignore privileged document (top level window, special about:* pages, …).
+ if (window.isChromeWindow) {
+ return false;
+ }
+
+ return true;
+ }
+
+ async showOverlay() {
+ if (
+ this.isPaused() &&
+ this._canShowOverlay() &&
+ this._parent.on &&
+ this.pauseOverlay
+ ) {
+ const reason = this._priorPause.why.type;
+ await this.pauseOverlay.isReady;
+
+ // we might not be paused anymore.
+ if (!this.isPaused()) {
+ return;
+ }
+
+ this.pauseOverlay.show(reason);
+ }
+ }
+
+ hideOverlay() {
+ if (this._canShowOverlay() && this._pauseOverlay) {
+ this.pauseOverlay.hide();
+ }
+ }
+
+ /**
+ * Tell the thread to automatically add a breakpoint on the first line of
+ * a given file, when it is first loaded.
+ *
+ * This is currently only used by the xpcshell test harness, and unless
+ * we decide to expand the scope of this feature, we should keep it that way.
+ */
+ setBreakpointOnLoad(urls) {
+ this._onLoadBreakpointURLs = new Set(urls);
+ }
+
+ _findXHRBreakpointIndex(p, m) {
+ return this._xhrBreakpoints.findIndex(
+ ({ path, method }) => path === p && method === m
+ );
+ }
+
+ // We clear the priorPause field when a breakpoint is added or removed
+ // at the same location because we are no longer worried about pausing twice
+ // at that location (e.g. debugger statement, stepping).
+ _maybeClearPriorPause(location) {
+ if (!this._priorPause) {
+ return;
+ }
+
+ const { where } = this._priorPause.frame;
+ if (where.line === location.line && where.column === location.column) {
+ this._priorPause = null;
+ }
+ }
+
+ async setBreakpoint(location, options) {
+ let actor = this.breakpointActorMap.get(location);
+ // Avoid resetting the exact same breakpoint twice
+ if (actor && JSON.stringify(actor.options) == JSON.stringify(options)) {
+ return;
+ }
+ if (!actor) {
+ actor = this.breakpointActorMap.getOrCreateBreakpointActor(location);
+ }
+ actor.setOptions(options);
+ this._maybeClearPriorPause(location);
+
+ if (location.sourceUrl) {
+ // There can be multiple source actors for a URL if there are multiple
+ // inline sources on an HTML page.
+ const sourceActors = this.sourcesManager.getSourceActorsByURL(
+ location.sourceUrl
+ );
+ for (const sourceActor of sourceActors) {
+ await sourceActor.applyBreakpoint(actor);
+ }
+ } else {
+ const sourceActor = this.sourcesManager.getSourceActorById(
+ location.sourceId
+ );
+ if (sourceActor) {
+ await sourceActor.applyBreakpoint(actor);
+ }
+ }
+ }
+
+ removeBreakpoint(location) {
+ const actor = this.breakpointActorMap.getOrCreateBreakpointActor(location);
+ this._maybeClearPriorPause(location);
+ actor.delete();
+ }
+
+ removeAllXHRBreakpoints() {
+ this._xhrBreakpoints = [];
+ return this._updateNetworkObserver();
+ }
+
+ removeXHRBreakpoint(path, method) {
+ const index = this._findXHRBreakpointIndex(path, method);
+
+ if (index >= 0) {
+ this._xhrBreakpoints.splice(index, 1);
+ }
+ return this._updateNetworkObserver();
+ }
+
+ setXHRBreakpoint(path, method) {
+ // request.path is a string,
+ // If requested url contains the path, then we pause.
+ const index = this._findXHRBreakpointIndex(path, method);
+
+ if (index === -1) {
+ this._xhrBreakpoints.push({ path, method });
+ }
+ return this._updateNetworkObserver();
+ }
+
+ getAvailableEventBreakpoints() {
+ return getAvailableEventBreakpoints(this._parent.window);
+ }
+ getActiveEventBreakpoints() {
+ return Array.from(this._activeEventBreakpoints);
+ }
+
+ /**
+ * Add event breakpoints to the list of active event breakpoints
+ *
+ * @param {Array<String>} ids: events to add (e.g. ["event.mouse.click","event.mouse.mousedown"])
+ */
+ addEventBreakpoints(ids) {
+ this.setActiveEventBreakpoints(
+ this.getActiveEventBreakpoints().concat(ids)
+ );
+ }
+
+ /**
+ * Remove event breakpoints from the list of active event breakpoints
+ *
+ * @param {Array<String>} ids: events to remove (e.g. ["event.mouse.click","event.mouse.mousedown"])
+ */
+ removeEventBreakpoints(ids) {
+ this.setActiveEventBreakpoints(
+ this.getActiveEventBreakpoints().filter(eventBp => !ids.includes(eventBp))
+ );
+ }
+
+ /**
+ * Set the the list of active event breakpoints
+ *
+ * @param {Array<String>} ids: events to add breakpoint for (e.g. ["event.mouse.click","event.mouse.mousedown"])
+ */
+ setActiveEventBreakpoints(ids) {
+ this._activeEventBreakpoints = new Set(ids);
+
+ if (eventsRequireNotifications(ids)) {
+ this._debuggerNotificationObserver.addListener(
+ this._eventBreakpointListener
+ );
+ } else {
+ this._debuggerNotificationObserver.removeListener(
+ this._eventBreakpointListener
+ );
+ }
+
+ if (this._activeEventBreakpoints.has(firstStatementBreakpointId())) {
+ this._ensureFirstStatementBreakpointInitialized();
+
+ this._firstStatementBreakpoint.hit = frame =>
+ this._pauseAndRespondEventBreakpoint(
+ frame,
+ firstStatementBreakpointId()
+ );
+ } else if (this._firstStatementBreakpoint) {
+ // Disabling the breakpoint disables the feature as much as we need it
+ // to. We do not bother removing breakpoints from the scripts themselves
+ // here because the breakpoints will be a no-op if `hit` is `null`, and
+ // if we wanted to remove them, we'd need a way to iterate through them
+ // all, which would require us to hold strong references to them, which
+ // just isn't needed. Plus, if the user disables and then re-enables the
+ // feature again later, the breakpoints will still be there to work.
+ this._firstStatementBreakpoint.hit = null;
+ }
+ }
+
+ _ensureFirstStatementBreakpointInitialized() {
+ if (this._firstStatementBreakpoint) {
+ return;
+ }
+
+ this._firstStatementBreakpoint = { hit: null };
+ for (const script of this.dbg.findScripts()) {
+ this._maybeTrackFirstStatementBreakpoint(script);
+ }
+ }
+
+ _maybeTrackFirstStatementBreakpointForNewGlobal(global) {
+ if (this._firstStatementBreakpoint) {
+ for (const script of this.dbg.findScripts({ global })) {
+ this._maybeTrackFirstStatementBreakpoint(script);
+ }
+ }
+ }
+
+ _maybeTrackFirstStatementBreakpoint(script) {
+ if (
+ // If the feature is not enabled yet, there is nothing to do.
+ !this._firstStatementBreakpoint ||
+ // WASM files don't have a first statement.
+ script.format !== "js" ||
+ // All "top-level" scripts are non-functions, whether that's because
+ // the script is a module, a global script, or an eval or what.
+ script.isFunction
+ ) {
+ return;
+ }
+
+ const bps = script.getPossibleBreakpoints();
+
+ // Scripts aren't guaranteed to have a step start if for instance the
+ // file contains only function declarations, so in that case we try to
+ // fall back to whatever we can find.
+ let meta = bps.find(bp => bp.isStepStart) || bps[0];
+ if (!meta) {
+ // We've tried to avoid using `getAllColumnOffsets()` because the set of
+ // locations included in this list is very under-defined, but for this
+ // usecase it's not the end of the world. Maybe one day we could have an
+ // "onEnterFrame" that was scoped to a specific script to avoid this.
+ meta = script.getAllColumnOffsets()[0];
+ }
+
+ if (!meta) {
+ // Not certain that this is actually possible, but including for sanity
+ // so that we don't throw unexpectedly.
+ return;
+ }
+ script.setBreakpoint(meta.offset, this._firstStatementBreakpoint);
+ }
+
+ _onNewDebuggee(global) {
+ this._maybeTrackFirstStatementBreakpointForNewGlobal(global);
+ try {
+ this._debuggerNotificationObserver.connect(global.unsafeDereference());
+ } catch (e) {}
+ }
+
+ _updateNetworkObserver() {
+ // Workers don't have access to `Services` and even if they did, network
+ // requests are all dispatched to the main thread, so there would be
+ // nothing here to listen for. We'll need to revisit implementing
+ // XHR breakpoints for workers.
+ if (isWorker) {
+ return false;
+ }
+
+ if (this._xhrBreakpoints.length && !this._observingNetwork) {
+ this._observingNetwork = true;
+ Services.obs.addObserver(
+ this._onOpeningRequest,
+ "http-on-opening-request"
+ );
+ } else if (this._xhrBreakpoints.length === 0 && this._observingNetwork) {
+ this._observingNetwork = false;
+ Services.obs.removeObserver(
+ this._onOpeningRequest,
+ "http-on-opening-request"
+ );
+ }
+
+ return true;
+ }
+
+ _onOpeningRequest(subject) {
+ if (this.shouldSkipAnyBreakpoint) {
+ return;
+ }
+
+ const channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ const url = channel.URI.asciiSpec;
+ const requestMethod = channel.requestMethod;
+
+ let causeType = Ci.nsIContentPolicy.TYPE_OTHER;
+ if (channel.loadInfo) {
+ causeType = channel.loadInfo.externalContentPolicyType;
+ }
+
+ const isXHR =
+ causeType === Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST ||
+ causeType === Ci.nsIContentPolicy.TYPE_FETCH;
+
+ if (!isXHR) {
+ // We currently break only if the request is either fetch or xhr
+ return;
+ }
+
+ let shouldPause = false;
+ for (const { path, method } of this._xhrBreakpoints) {
+ if (method !== "ANY" && method !== requestMethod) {
+ continue;
+ }
+ if (url.includes(path)) {
+ shouldPause = true;
+ break;
+ }
+ }
+
+ if (shouldPause) {
+ const frame = this.dbg.getNewestFrame();
+
+ // If there is no frame, this request was dispatched by logic that isn't
+ // primarily JS, so pausing the event loop wouldn't make sense.
+ // This covers background requests like loading the initial page document,
+ // or loading favicons. This also includes requests dispatched indirectly
+ // from workers. We'll need to handle them separately in the future.
+ if (frame) {
+ this._pauseAndRespond(frame, { type: PAUSE_REASONS.XHR });
+ }
+ }
+ }
+
+ reconfigure(options = {}) {
+ if (this.state == STATES.EXITED) {
+ throw {
+ error: "wrongState",
+ };
+ }
+ this._options = { ...this._options, ...options };
+
+ if ("observeAsmJS" in options) {
+ this.dbg.allowUnobservedAsmJS = !options.observeAsmJS;
+ }
+ if ("observeWasm" in options) {
+ this.dbg.allowUnobservedWasm = !options.observeWasm;
+ }
+
+ if (
+ "pauseWorkersUntilAttach" in options &&
+ this._parent.pauseWorkersUntilAttach
+ ) {
+ this._parent.pauseWorkersUntilAttach(options.pauseWorkersUntilAttach);
+ }
+
+ if (options.breakpoints) {
+ for (const breakpoint of Object.values(options.breakpoints)) {
+ this.setBreakpoint(breakpoint.location, breakpoint.options);
+ }
+ }
+
+ if (options.eventBreakpoints) {
+ this.setActiveEventBreakpoints(options.eventBreakpoints);
+ }
+
+ // Only consider this options if an explicit boolean value is passed.
+ if (typeof this._options.shouldPauseOnDebuggerStatement == "boolean") {
+ this.setPauseOnDebuggerStatement(
+ this._options.shouldPauseOnDebuggerStatement
+ );
+ }
+ this.setPauseOnExceptions(this._options.pauseOnExceptions);
+ }
+
+ _eventBreakpointListener(notification) {
+ if (this._state === STATES.PAUSED || this._state === STATES.DETACHED) {
+ return;
+ }
+
+ const eventBreakpoint = eventBreakpointForNotification(
+ this.dbg,
+ notification
+ );
+
+ if (!this._activeEventBreakpoints.has(eventBreakpoint)) {
+ return;
+ }
+
+ if (notification.phase === "pre" && !this._activeEventPause) {
+ this._activeEventPause = this._captureDebuggerHooks();
+
+ this.dbg.onEnterFrame =
+ this._makeEventBreakpointEnterFrame(eventBreakpoint);
+ } else if (notification.phase === "post" && this._activeEventPause) {
+ this._restoreDebuggerHooks(this._activeEventPause);
+ this._activeEventPause = null;
+ } else if (!notification.phase && !this._activeEventPause) {
+ const frame = this.dbg.getNewestFrame();
+ if (frame) {
+ if (this.sourcesManager.isFrameBlackBoxed(frame)) {
+ return;
+ }
+
+ this._pauseAndRespondEventBreakpoint(frame, eventBreakpoint);
+ }
+ }
+ }
+
+ _makeEventBreakpointEnterFrame(eventBreakpoint) {
+ return frame => {
+ if (this.sourcesManager.isFrameBlackBoxed(frame)) {
+ return undefined;
+ }
+
+ this._restoreDebuggerHooks(this._activeEventPause);
+ this._activeEventPause = null;
+
+ return this._pauseAndRespondEventBreakpoint(frame, eventBreakpoint);
+ };
+ }
+
+ _pauseAndRespondEventBreakpoint(frame, eventBreakpoint) {
+ if (this.shouldSkipAnyBreakpoint) {
+ return undefined;
+ }
+
+ if (this._options.logEventBreakpoints) {
+ return logEvent({
+ threadActor: this,
+ frame,
+ level: "logPoint",
+ expression: `[_event]`,
+ bindings: { _event: frame.arguments[0] },
+ });
+ }
+
+ return this._pauseAndRespond(frame, {
+ type: PAUSE_REASONS.EVENT_BREAKPOINT,
+ breakpoint: eventBreakpoint,
+ message: makeEventBreakpointMessage(eventBreakpoint),
+ });
+ }
+
+ _captureDebuggerHooks() {
+ return {
+ onEnterFrame: this.dbg.onEnterFrame,
+ onStep: this.dbg.onStep,
+ onPop: this.dbg.onPop,
+ };
+ }
+
+ _restoreDebuggerHooks(hooks) {
+ this.dbg.onEnterFrame = hooks.onEnterFrame;
+ this.dbg.onStep = hooks.onStep;
+ this.dbg.onPop = hooks.onPop;
+ }
+
+ /**
+ * Pause the debuggee, by entering a nested event loop, and return a 'paused'
+ * packet to the client.
+ *
+ * @param Debugger.Frame frame
+ * The newest debuggee frame in the stack.
+ * @param object reason
+ * An object with a 'type' property containing the reason for the pause.
+ * @param function onPacket
+ * Hook to modify the packet before it is sent. Feel free to return a
+ * promise.
+ */
+ _pauseAndRespond(frame, reason, onPacket = k => k) {
+ try {
+ const packet = this._paused(frame);
+ if (!packet) {
+ return undefined;
+ }
+
+ const { sourceActor, line, column } =
+ this.sourcesManager.getFrameLocation(frame);
+
+ packet.why = reason;
+
+ if (!sourceActor) {
+ // If the frame location is in a source that not pass the 'isHiddenSource'
+ // check and thus has no actor, we do not bother pausing.
+ return undefined;
+ }
+
+ packet.frame.where = {
+ actor: sourceActor.actorID,
+ line,
+ column,
+ };
+ const pkt = onPacket(packet);
+
+ this._priorPause = pkt;
+ this.emit("paused", pkt);
+ this.showOverlay();
+ } catch (error) {
+ reportException("DBG-SERVER", error);
+ this.conn.send({
+ error: "unknownError",
+ message: error.message + "\n" + error.stack,
+ });
+ return undefined;
+ }
+
+ try {
+ this._nestedEventLoop.enter();
+ } catch (e) {
+ reportException("TA__pauseAndRespond", e);
+ }
+
+ if (this._requestedFrameRestart) {
+ return null;
+ }
+
+ // If the parent actor has been closed, terminate the debuggee script
+ // instead of continuing. Executing JS after the content window is gone is
+ // a bad idea.
+ return this._parentClosed ? null : undefined;
+ }
+
+ _makeOnEnterFrame({ pauseAndRespond }) {
+ return frame => {
+ if (this._requestedFrameRestart) {
+ return null;
+ }
+
+ // Continue forward until we get to a valid step target.
+ const { onStep, onPop } = this._makeSteppingHooks({
+ steppingType: "next",
+ });
+
+ if (this.sourcesManager.isFrameBlackBoxed(frame)) {
+ return undefined;
+ }
+
+ frame.onStep = onStep;
+ frame.onPop = onPop;
+ return undefined;
+ };
+ }
+
+ _makeOnPop({ pauseAndRespond, steppingType }) {
+ const thread = this;
+ return function (completion) {
+ if (thread._requestedFrameRestart === this) {
+ return thread.restartFrame(this);
+ }
+
+ // onPop is called when we temporarily leave an async/generator
+ if (steppingType != "finish" && (completion.await || completion.yield)) {
+ thread.suspendedFrame = this;
+ thread.dbg.onEnterFrame = undefined;
+ return undefined;
+ }
+
+ // Note that we're popping this frame; we need to watch for
+ // subsequent step events on its caller.
+ this.reportedPop = true;
+
+ // Cache the frame so that the onPop and onStep hooks are cleared
+ // on the next pause.
+ thread.suspendedFrame = this;
+
+ if (
+ steppingType != "finish" &&
+ !thread.sourcesManager.isFrameBlackBoxed(this)
+ ) {
+ const pauseAndRespValue = pauseAndRespond(this, packet =>
+ thread.createCompletionGrip(packet, completion)
+ );
+
+ // If the requested frame to restart differs from this frame, we don't
+ // need to restart it at this point.
+ if (thread._requestedFrameRestart === this) {
+ return thread.restartFrame(this);
+ }
+
+ return pauseAndRespValue;
+ }
+
+ thread._attachSteppingHooks(this, "next", completion);
+ return undefined;
+ };
+ }
+
+ restartFrame(frame) {
+ this._requestedFrameRestart = null;
+ this._priorPause = null;
+
+ if (
+ frame.type !== "call" ||
+ frame.script.isGeneratorFunction ||
+ frame.script.isAsyncFunction
+ ) {
+ return undefined;
+ }
+ RESTARTED_FRAMES.add(frame);
+
+ const completion = frame.callee.apply(frame.this, frame.arguments);
+
+ return completion;
+ }
+
+ hasMoved(frame, newType) {
+ const newLocation = this.sourcesManager.getFrameLocation(frame);
+
+ if (!this._priorPause) {
+ return true;
+ }
+
+ // Recursion/Loops makes it okay to resume and land at
+ // the same breakpoint or debugger statement.
+ // It is not okay to transition from a breakpoint to debugger statement
+ // or a step to a debugger statement.
+ const { type } = this._priorPause.why;
+
+ // Conditional breakpoint are doing something weird as they are using "breakpoint" type
+ // unless they throw in which case they will be "breakpointConditionThrown".
+ if (
+ type == newType ||
+ (type == "breakpointConditionThrown" && newType == "breakpoint")
+ ) {
+ return true;
+ }
+
+ const { line, column } = this._priorPause.frame.where;
+ return line !== newLocation.line || column !== newLocation.column;
+ }
+
+ _makeOnStep({ pauseAndRespond, startFrame, steppingType, completion }) {
+ const thread = this;
+ return function () {
+ if (thread._validFrameStepOffset(this, startFrame, this.offset)) {
+ return pauseAndRespond(this, packet =>
+ thread.createCompletionGrip(packet, completion)
+ );
+ }
+
+ return undefined;
+ };
+ }
+
+ _validFrameStepOffset(frame, startFrame, offset) {
+ const meta = frame.script.getOffsetMetadata(offset);
+
+ // Continue if:
+ // 1. the location is not a valid breakpoint position
+ // 2. the source is blackboxed
+ // 3. we have not moved since the last pause
+ if (
+ !meta.isBreakpoint ||
+ this.sourcesManager.isFrameBlackBoxed(frame) ||
+ !this.hasMoved(frame)
+ ) {
+ return false;
+ }
+
+ // Pause if:
+ // 1. the frame has changed
+ // 2. the location is a step position.
+ return frame !== startFrame || meta.isStepStart;
+ }
+
+ atBreakpointLocation(frame) {
+ const location = this.sourcesManager.getFrameLocation(frame);
+ return !!this.breakpointActorMap.get(location);
+ }
+
+ createCompletionGrip(packet, completion) {
+ if (!completion) {
+ return packet;
+ }
+
+ const createGrip = value =>
+ createValueGrip(value, this._pausePool, this.objectGrip);
+ packet.why.frameFinished = {};
+
+ if (completion.hasOwnProperty("return")) {
+ packet.why.frameFinished.return = createGrip(completion.return);
+ } else if (completion.hasOwnProperty("yield")) {
+ packet.why.frameFinished.return = createGrip(completion.yield);
+ } else if (completion.hasOwnProperty("throw")) {
+ packet.why.frameFinished.throw = createGrip(completion.throw);
+ }
+
+ return packet;
+ }
+
+ /**
+ * Define the JS hook functions for stepping.
+ */
+ _makeSteppingHooks({ steppingType, startFrame, completion }) {
+ // Bind these methods and state because some of the hooks are called
+ // with 'this' set to the current frame. Rather than repeating the
+ // binding in each _makeOnX method, just do it once here and pass it
+ // in to each function.
+ const steppingHookState = {
+ pauseAndRespond: (frame, onPacket = k => k) =>
+ this._pauseAndRespond(
+ frame,
+ { type: PAUSE_REASONS.RESUME_LIMIT },
+ onPacket
+ ),
+ startFrame: startFrame || this.youngestFrame,
+ steppingType,
+ completion,
+ };
+
+ return {
+ onEnterFrame: this._makeOnEnterFrame(steppingHookState),
+ onPop: this._makeOnPop(steppingHookState),
+ onStep: this._makeOnStep(steppingHookState),
+ };
+ }
+
+ /**
+ * Handle attaching the various stepping hooks we need to attach when we
+ * receive a resume request with a resumeLimit property.
+ *
+ * @param Object { resumeLimit }
+ * The values received over the RDP.
+ * @returns A promise that resolves to true once the hooks are attached, or is
+ * rejected with an error packet.
+ */
+ async _handleResumeLimit({ resumeLimit, frameActorID }) {
+ const steppingType = resumeLimit.type;
+ if (
+ !["break", "step", "next", "finish", "restart"].includes(steppingType)
+ ) {
+ return Promise.reject({
+ error: "badParameterType",
+ message: "Unknown resumeLimit type",
+ });
+ }
+
+ let frame = this.youngestFrame;
+
+ if (frameActorID) {
+ frame = this._framesPool.getActorByID(frameActorID).frame;
+ if (!frame) {
+ throw new Error("Frame should exist in the frames pool.");
+ }
+ }
+
+ if (steppingType === "restart") {
+ if (
+ frame.type !== "call" ||
+ frame.script.isGeneratorFunction ||
+ frame.script.isAsyncFunction
+ ) {
+ return undefined;
+ }
+ this._requestedFrameRestart = frame;
+ }
+
+ return this._attachSteppingHooks(frame, steppingType, undefined);
+ }
+
+ _attachSteppingHooks(frame, steppingType, completion) {
+ // If we are stepping out of the onPop handler, we want to use "next" mode
+ // so that the parent frame's handlers behave consistently.
+ if (steppingType === "finish" && frame.reportedPop) {
+ steppingType = "next";
+ }
+
+ // If there are no more frames on the stack, use "step" mode so that we will
+ // pause on the next script to execute.
+ const stepFrame = this._getNextStepFrame(frame);
+ if (!stepFrame) {
+ steppingType = "step";
+ }
+
+ const { onEnterFrame, onPop, onStep } = this._makeSteppingHooks({
+ steppingType,
+ completion,
+ startFrame: frame,
+ });
+
+ if (steppingType === "step" || steppingType === "restart") {
+ this.dbg.onEnterFrame = onEnterFrame;
+ }
+
+ if (stepFrame) {
+ switch (steppingType) {
+ case "step":
+ case "break":
+ case "next":
+ if (stepFrame.script) {
+ if (!this.sourcesManager.isFrameBlackBoxed(stepFrame)) {
+ stepFrame.onStep = onStep;
+ }
+ }
+ // eslint-disable-next-line no-fallthrough
+ case "finish":
+ stepFrame.onStep = createStepForReactionTracking(stepFrame.onStep);
+ // eslint-disable-next-line no-fallthrough
+ case "restart":
+ stepFrame.onPop = onPop;
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Clear the onStep and onPop hooks for all frames on the stack.
+ */
+ _clearSteppingHooks() {
+ if (this.suspendedFrame) {
+ this.suspendedFrame.onStep = undefined;
+ this.suspendedFrame.onPop = undefined;
+ this.suspendedFrame = undefined;
+ }
+
+ let frame = this.youngestFrame;
+ if (frame?.onStack) {
+ while (frame) {
+ frame.onStep = undefined;
+ frame.onPop = undefined;
+ frame = frame.older;
+ }
+ }
+ }
+
+ /**
+ * Handle a protocol request to resume execution of the debuggee.
+ */
+ async resume(resumeLimit, frameActorID) {
+ if (this._state !== STATES.PAUSED) {
+ return {
+ error: "wrongState",
+ message:
+ "Can't resume when debuggee isn't paused. Current state is '" +
+ this._state +
+ "'",
+ state: this._state,
+ };
+ }
+
+ // In case of multiple nested event loops (due to multiple debuggers open in
+ // different tabs or multiple devtools clients connected to the same tab)
+ // only allow resumption in a LIFO order.
+ if (!this._nestedEventLoop.isTheLastPausedThreadActor()) {
+ return {
+ error: "wrongOrder",
+ message: "trying to resume in the wrong order.",
+ };
+ }
+
+ try {
+ if (resumeLimit) {
+ await this._handleResumeLimit({ resumeLimit, frameActorID });
+ } else {
+ this._clearSteppingHooks();
+ }
+
+ this.doResume({ resumeLimit });
+ return {};
+ } catch (error) {
+ return error instanceof Error
+ ? {
+ error: "unknownError",
+ message: DevToolsUtils.safeErrorString(error),
+ }
+ : // It is a known error, and the promise was rejected with an error
+ // packet.
+ error;
+ }
+ }
+
+ /**
+ * Only resume and notify necessary observers. This should be used in cases
+ * when we do not want to notify the front end of a resume, for example when
+ * we are shutting down.
+ */
+ doResume({ resumeLimit } = {}) {
+ this._state = STATES.RUNNING;
+
+ // Drop the actors in the pause actor pool.
+ this._pausePool.destroy();
+ this._pausePool = null;
+
+ this._pauseActor = null;
+ this._nestedEventLoop.exit();
+
+ // Tell anyone who cares of the resume (as of now, that's the xpcshell harness and
+ // devtools-startup.js when handling the --wait-for-jsdebugger flag)
+ this.emit("resumed");
+ this.hideOverlay();
+ }
+
+ /**
+ * Set the debugging hook to pause on exceptions if configured to do so.
+ *
+ * Note that this is also called when evaluating conditional breakpoints.
+ *
+ * @param {Boolean} doPause
+ * Should watch for pause or not. `_onExceptionUnwind` function will
+ * then be notified about new caught or uncaught exception being fired.
+ */
+ setPauseOnExceptions(doPause) {
+ if (doPause) {
+ this.dbg.onExceptionUnwind = this._onExceptionUnwind;
+ } else {
+ this.dbg.onExceptionUnwind = undefined;
+ }
+ }
+
+ /**
+ * Set the debugging hook to pause on debugger statement if configured to do so.
+ *
+ * Note that the thread actor will pause on exception by default.
+ * This method has to be called with a falsy value to disable it.
+ *
+ * @param {Boolean} doPause
+ * Controls whether we should or should not pause on debugger statement.
+ */
+ setPauseOnDebuggerStatement(doPause) {
+ this.dbg.onDebuggerStatement = doPause
+ ? this.onDebuggerStatement
+ : undefined;
+ }
+
+ isPauseOnExceptionsEnabled() {
+ return this.dbg.onExceptionUnwind == this._onExceptionUnwind;
+ }
+
+ /**
+ * Helper method that returns the next frame when stepping.
+ */
+ _getNextStepFrame(frame) {
+ const endOfFrame = frame.reportedPop;
+ const stepFrame = endOfFrame
+ ? frame.older || getAsyncParentFrame(frame)
+ : frame;
+ if (!stepFrame || !stepFrame.script) {
+ return null;
+ }
+
+ // Skips a frame that has been restarted.
+ if (RESTARTED_FRAMES.has(stepFrame)) {
+ return this._getNextStepFrame(stepFrame.older);
+ }
+
+ return stepFrame;
+ }
+
+ frames(start, count) {
+ if (this.state !== STATES.PAUSED) {
+ return {
+ error: "wrongState",
+ message:
+ "Stack frames are only available while the debuggee is paused.",
+ };
+ }
+
+ // Find the starting frame...
+ let frame = this.youngestFrame;
+
+ const walkToParentFrame = () => {
+ if (!frame) {
+ return;
+ }
+
+ const currentFrame = frame;
+ frame = null;
+
+ if (!(currentFrame instanceof Debugger.Frame)) {
+ frame = getSavedFrameParent(this, currentFrame);
+ } else if (currentFrame.older) {
+ frame = currentFrame.older;
+ } else if (
+ this._options.shouldIncludeSavedFrames &&
+ currentFrame.olderSavedFrame
+ ) {
+ frame = currentFrame.olderSavedFrame;
+ if (frame && !isValidSavedFrame(this, frame)) {
+ frame = null;
+ }
+ } else if (
+ this._options.shouldIncludeAsyncLiveFrames &&
+ currentFrame.asyncPromise
+ ) {
+ const asyncFrame = getAsyncParentFrame(currentFrame);
+ if (asyncFrame) {
+ frame = asyncFrame;
+ }
+ }
+ };
+
+ let i = 0;
+ while (frame && i < start) {
+ walkToParentFrame();
+ i++;
+ }
+
+ // Return count frames, or all remaining frames if count is not defined.
+ const frames = [];
+ for (; frame && (!count || i < start + count); i++, walkToParentFrame()) {
+ // SavedFrame instances don't have direct Debugger.Source object. If
+ // there is an active Debugger.Source that represents the SaveFrame's
+ // source, it will have already been created in the server.
+ if (frame instanceof Debugger.Frame) {
+ this.sourcesManager.createSourceActor(frame.script.source);
+ }
+
+ if (RESTARTED_FRAMES.has(frame)) {
+ continue;
+ }
+
+ const frameActor = this._createFrameActor(frame, i);
+ frames.push(frameActor);
+ }
+
+ return { frames };
+ }
+
+ addAllSources() {
+ // Compare the sources we find with the source URLs which have been loaded
+ // in debuggee realms. Count the number of sources associated with each
+ // URL so that we can detect if an HTML file has had some inline sources
+ // collected but not all.
+ const urlMap = {};
+ for (const url of this.dbg.findSourceURLs()) {
+ if (url !== "self-hosted") {
+ if (!urlMap[url]) {
+ urlMap[url] = { count: 0, sources: [] };
+ }
+ urlMap[url].count++;
+ }
+ }
+
+ const sources = this.dbg.findSources();
+
+ for (const source of sources) {
+ this._addSource(source);
+
+ // The following check should match the filtering done by `findSourceURLs`:
+ // https://searchfox.org/mozilla-central/rev/ac7a567f036e1954542763f4722fbfce041fb752/js/src/debugger/Debugger.cpp#2406-2409
+ // Otherwise we may populate `urlMap` incorrectly and resurrect sources that weren't GCed,
+ // and spawn duplicated SourceActors/Debugger.Source for the same actual source.
+ // `findSourceURLs` uses !introductionScript check as that allows to identify <script>'s
+ // loaded from the HTML page. This boolean will be defined only when the <script> tag
+ // is added by Javascript code at runtime.
+ // https://searchfox.org/mozilla-central/rev/3d03a3ca09f03f06ef46a511446537563f62a0c6/devtools/docs/user/debugger-api/debugger.source/index.rst#113
+ if (!source.introductionScript && urlMap[source.url]) {
+ urlMap[source.url].count--;
+ urlMap[source.url].sources.push(source);
+ }
+ }
+
+ // Resurrect any URLs for which not all sources are accounted for.
+ for (const [url, data] of Object.entries(urlMap)) {
+ if (data.count > 0) {
+ this._resurrectSource(url, data.sources);
+ }
+ }
+ }
+
+ sources(request) {
+ this.addAllSources();
+
+ // No need to flush the new source packets here, as we are sending the
+ // list of sources out immediately and we don't need to invoke the
+ // overhead of an RDP packet for every source right now. Let the default
+ // timeout flush the buffered packets.
+
+ return this.sourcesManager.iter().map(s => s.form());
+ }
+
+ /**
+ * Disassociate all breakpoint actors from their scripts and clear the
+ * breakpoint handlers. This method can be used when the thread actor intends
+ * to keep the breakpoint store, but needs to clear any actual breakpoints,
+ * e.g. due to a page navigation. This way the breakpoint actors' script
+ * caches won't hold on to the Debugger.Script objects leaking memory.
+ */
+ disableAllBreakpoints() {
+ for (const bpActor of this.breakpointActorMap.findActors()) {
+ bpActor.removeScripts();
+ }
+ }
+
+ removeAllBreakpoints() {
+ this.breakpointActorMap.removeAllBreakpoints();
+ }
+
+ removeAllWatchpoints() {
+ for (const actor of this.threadLifetimePool.poolChildren()) {
+ if (actor.typeName == "obj") {
+ actor.removeWatchpoints();
+ }
+ }
+ }
+
+ addWatchpoint(objActor, data) {
+ this._watchpointsMap.add(objActor, data);
+ }
+
+ removeWatchpoint(objActor, property) {
+ this._watchpointsMap.remove(objActor, property);
+ }
+
+ getWatchpoint(obj, property) {
+ return this._watchpointsMap.get(obj, property);
+ }
+
+ /**
+ * Handle a protocol request to pause the debuggee.
+ */
+ interrupt(when) {
+ if (this.state == STATES.EXITED) {
+ return { type: "exited" };
+ } else if (this.state == STATES.PAUSED) {
+ // TODO: return the actual reason for the existing pause.
+ this.emit("paused", {
+ why: { type: PAUSE_REASONS.ALREADY_PAUSED },
+ });
+ return {};
+ } else if (this.state != STATES.RUNNING) {
+ return {
+ error: "wrongState",
+ message: "Received interrupt request in " + this.state + " state.",
+ };
+ }
+ try {
+ // If execution should pause just before the next JavaScript bytecode is
+ // executed, just set an onEnterFrame handler.
+ if (when == "onNext") {
+ const onEnterFrame = frame => {
+ this._pauseAndRespond(frame, {
+ type: PAUSE_REASONS.INTERRUPTED,
+ onNext: true,
+ });
+ };
+ this.dbg.onEnterFrame = onEnterFrame;
+ return {};
+ }
+
+ // If execution should pause immediately, just put ourselves in the paused
+ // state.
+ const packet = this._paused();
+ if (!packet) {
+ return { error: "notInterrupted" };
+ }
+ packet.why = { type: PAUSE_REASONS.INTERRUPTED, onNext: false };
+
+ // Send the response to the interrupt request now (rather than
+ // returning it), because we're going to start a nested event loop
+ // here.
+ this.conn.send({ from: this.actorID, type: "interrupt" });
+ this.emit("paused", packet);
+
+ // Start a nested event loop.
+ this._nestedEventLoop.enter();
+
+ // We already sent a response to this request, don't send one
+ // now.
+ return null;
+ } catch (e) {
+ reportException("DBG-SERVER", e);
+ return { error: "notInterrupted", message: e.toString() };
+ }
+ }
+
+ _paused(frame) {
+ // We don't handle nested pauses correctly. Don't try - if we're
+ // paused, just continue running whatever code triggered the pause.
+ // We don't want to actually have nested pauses (although we
+ // have nested event loops). If code runs in the debuggee during
+ // a pause, it should cause the actor to resume (dropping
+ // pause-lifetime actors etc) and then repause when complete.
+
+ if (this.state === STATES.PAUSED) {
+ return undefined;
+ }
+
+ this._state = STATES.PAUSED;
+
+ // Clear stepping hooks.
+ this.dbg.onEnterFrame = undefined;
+ this._requestedFrameRestart = null;
+ this._clearSteppingHooks();
+
+ // Create the actor pool that will hold the pause actor and its
+ // children.
+ assert(!this._pausePool, "No pause pool should exist yet");
+ this._pausePool = new Pool(this.conn, "pause");
+
+ // Give children of the pause pool a quick link back to the
+ // thread...
+ this._pausePool.threadActor = this;
+
+ // Create the pause actor itself...
+ assert(!this._pauseActor, "No pause actor should exist yet");
+ this._pauseActor = new PauseActor(this._pausePool);
+ this._pausePool.manage(this._pauseActor);
+
+ // Update the list of frames.
+ this._updateFrames();
+
+ // Send off the paused packet and spin an event loop.
+ const packet = {
+ actor: this._pauseActor.actorID,
+ };
+
+ if (frame) {
+ packet.frame = this._createFrameActor(frame);
+ }
+
+ return packet;
+ }
+
+ /**
+ * Expire frame actors for frames that are no longer on the current stack.
+ */
+ _updateFrames() {
+ // Create the actor pool that will hold the still-living frames.
+ const framesPool = new Pool(this.conn, "frames");
+ const frameList = [];
+
+ for (const frameActor of this._frameActors) {
+ if (frameActor.frame.onStack) {
+ framesPool.manage(frameActor);
+ frameList.push(frameActor);
+ }
+ }
+
+ // Remove the old frame actor pool, this will expire
+ // any actors that weren't added to the new pool.
+ if (this._framesPool) {
+ this._framesPool.destroy();
+ }
+
+ this._frameActors = frameList;
+ this._framesPool = framesPool;
+ }
+
+ _createFrameActor(frame, depth) {
+ let actor = this._frameActorMap.get(frame);
+ if (!actor || actor.isDestroyed()) {
+ actor = new FrameActor(frame, this, depth);
+ this._frameActors.push(actor);
+ this._framesPool.manage(actor);
+
+ this._frameActorMap.set(frame, actor);
+ }
+ return actor;
+ }
+
+ /**
+ * Create and return an environment actor that corresponds to the provided
+ * Debugger.Environment.
+ * @param Debugger.Environment environment
+ * The lexical environment we want to extract.
+ * @param object pool
+ * The pool where the newly-created actor will be placed.
+ * @return The EnvironmentActor for environment or undefined for host
+ * functions or functions scoped to a non-debuggee global.
+ */
+ createEnvironmentActor(environment, pool) {
+ if (!environment) {
+ return undefined;
+ }
+
+ if (environment.actor) {
+ return environment.actor;
+ }
+
+ const actor = new EnvironmentActor(environment, this);
+ pool.manage(actor);
+ environment.actor = actor;
+
+ return actor;
+ }
+
+ /**
+ * Create a grip for the given debuggee object.
+ *
+ * @param value Debugger.Object
+ * The debuggee object value.
+ * @param pool Pool
+ * The actor pool where the new object actor will be added.
+ */
+ objectGrip(value, pool) {
+ if (!pool.objectActors) {
+ pool.objectActors = new WeakMap();
+ }
+
+ if (pool.objectActors.has(value)) {
+ return pool.objectActors.get(value).form();
+ }
+
+ if (this.threadLifetimePool.objectActors.has(value)) {
+ return this.threadLifetimePool.objectActors.get(value).form();
+ }
+
+ const actor = new PauseScopedObjectActor(
+ value,
+ {
+ thread: this,
+ getGripDepth: () => this._gripDepth,
+ incrementGripDepth: () => this._gripDepth++,
+ decrementGripDepth: () => this._gripDepth--,
+ createValueGrip: v => {
+ if (this._pausePool) {
+ return createValueGrip(v, this._pausePool, this.pauseObjectGrip);
+ }
+
+ return createValueGrip(v, this.threadLifetimePool, this.objectGrip);
+ },
+ createEnvironmentActor: (e, p) => this.createEnvironmentActor(e, p),
+ promote: () => this.threadObjectGrip(actor),
+ isThreadLifetimePool: () =>
+ actor.getParent() !== this.threadLifetimePool,
+ },
+ this.conn
+ );
+ pool.manage(actor);
+ pool.objectActors.set(value, actor);
+ return actor.form();
+ }
+
+ /**
+ * Create a grip for the given debuggee object with a pause lifetime.
+ *
+ * @param value Debugger.Object
+ * The debuggee object value.
+ */
+ pauseObjectGrip(value) {
+ if (!this._pausePool) {
+ throw new Error("Object grip requested while not paused.");
+ }
+
+ return this.objectGrip(value, this._pausePool);
+ }
+
+ /**
+ * Extend the lifetime of the provided object actor to thread lifetime.
+ *
+ * @param actor object
+ * The object actor.
+ */
+ threadObjectGrip(actor) {
+ this.threadLifetimePool.manage(actor);
+ this.threadLifetimePool.objectActors.set(actor.obj, actor);
+ }
+
+ _onWindowReady({ isTopLevel, isBFCache, window }) {
+ // Note that this code relates to the disabling of Debugger API from will-navigate listener.
+ // And should only be triggered when the target actor doesn't follow WindowGlobal lifecycle.
+ // i.e. when the Thread Actor manages more than one top level WindowGlobal.
+ if (isTopLevel && this.state != STATES.DETACHED) {
+ this.sourcesManager.reset();
+ this.clearDebuggees();
+ this.dbg.enable();
+ // Update the global no matter if the debugger is on or off,
+ // otherwise the global will be wrong when enabled later.
+ this.global = window;
+ }
+
+ // Refresh the debuggee list when a new window object appears (top window or
+ // iframe).
+ if (this.attached) {
+ this.dbg.addDebuggees();
+ }
+
+ // BFCache navigations reuse old sources, so send existing sources to the
+ // client instead of waiting for onNewScript debugger notifications.
+ if (isBFCache) {
+ this.addAllSources();
+ }
+ }
+
+ _onWillNavigate({ isTopLevel }) {
+ if (!isTopLevel) {
+ return;
+ }
+
+ // Proceed normally only if the debuggee is not paused.
+ if (this.state == STATES.PAUSED) {
+ // If we were paused while navigating to a new page,
+ // we resume previous page execution, so that the document can be sucessfully unloaded.
+ // And we disable the Debugger API, so that we do not hit any breakpoint or trigger any
+ // thread actor feature. We will re-enable it just before the next page starts loading,
+ // from window-ready listener. That's for when the target doesn't follow WindowGlobal
+ // lifecycle.
+ // When the target follows the WindowGlobal lifecycle, we will stiff resume and disable
+ // this thread actor. It will soon be destroyed. And a new target will pick up
+ // the next WindowGlobal and spawn a new Debugger API, via ThreadActor.attach().
+ this.doResume();
+ this.dbg.disable();
+ }
+
+ this.removeAllWatchpoints();
+ this.disableAllBreakpoints();
+ this.dbg.onEnterFrame = undefined;
+ }
+
+ _onNavigate() {
+ if (this.state == STATES.RUNNING) {
+ this.dbg.enable();
+ }
+ }
+
+ // JS Debugger API hooks.
+ pauseForMutationBreakpoint(
+ mutationType,
+ targetNode,
+ ancestorNode,
+ action = "" // "add" or "remove"
+ ) {
+ if (
+ !["subtreeModified", "nodeRemoved", "attributeModified"].includes(
+ mutationType
+ )
+ ) {
+ throw new Error("Unexpected mutation breakpoint type");
+ }
+
+ if (this.shouldSkipAnyBreakpoint) {
+ return undefined;
+ }
+
+ const frame = this.dbg.getNewestFrame();
+ if (!frame) {
+ return undefined;
+ }
+
+ if (this.sourcesManager.isFrameBlackBoxed(frame)) {
+ return undefined;
+ }
+
+ const global = (targetNode.ownerDocument || targetNode).defaultView;
+ assert(global && this.dbg.hasDebuggee(global));
+
+ const targetObj = this.dbg
+ .makeGlobalObjectReference(global)
+ .makeDebuggeeValue(targetNode);
+
+ let ancestorObj = null;
+ if (ancestorNode) {
+ ancestorObj = this.dbg
+ .makeGlobalObjectReference(global)
+ .makeDebuggeeValue(ancestorNode);
+ }
+
+ return this._pauseAndRespond(
+ frame,
+ {
+ type: PAUSE_REASONS.MUTATION_BREAKPOINT,
+ mutationType,
+ message: `DOM Mutation: '${mutationType}'`,
+ },
+ pkt => {
+ // We have to add this here because `_pausePool` is `null` beforehand.
+ pkt.why.nodeGrip = this.objectGrip(targetObj, this._pausePool);
+ pkt.why.ancestorGrip = ancestorObj
+ ? this.objectGrip(ancestorObj, this._pausePool)
+ : null;
+ pkt.why.action = action;
+ return pkt;
+ }
+ );
+ }
+
+ /**
+ * A function that the engine calls when a debugger statement has been
+ * executed in the specified frame.
+ *
+ * @param frame Debugger.Frame
+ * The stack frame that contained the debugger statement.
+ */
+ onDebuggerStatement(frame) {
+ // Don't pause if:
+ // 1. breakpoints are disabled
+ // 2. we have not moved since the last pause
+ // 3. the source is blackboxed
+ // 4. there is a breakpoint at the same location
+ if (
+ this.shouldSkipAnyBreakpoint ||
+ !this.hasMoved(frame, "debuggerStatement") ||
+ this.sourcesManager.isFrameBlackBoxed(frame) ||
+ this.atBreakpointLocation(frame)
+ ) {
+ return undefined;
+ }
+
+ return this._pauseAndRespond(frame, {
+ type: PAUSE_REASONS.DEBUGGER_STATEMENT,
+ });
+ }
+
+ skipBreakpoints(skip) {
+ this._options.skipBreakpoints = skip;
+ return { skip };
+ }
+
+ // Bug 1686485 is meant to remove usages of this request
+ // in favor direct call to `reconfigure`
+ pauseOnExceptions(pauseOnExceptions, ignoreCaughtExceptions) {
+ this.reconfigure({
+ pauseOnExceptions,
+ ignoreCaughtExceptions,
+ });
+ return {};
+ }
+
+ /**
+ * A function that the engine calls when an exception has been thrown and has
+ * propagated to the specified frame.
+ *
+ * @param youngestFrame Debugger.Frame
+ * The youngest remaining stack frame.
+ * @param value object
+ * The exception that was thrown.
+ */
+ _onExceptionUnwind(youngestFrame, value) {
+ // Ignore any reported exception if we are already paused
+ if (this.isPaused()) {
+ return undefined;
+ }
+
+ // Ignore shouldSkipAnyBreakpoint if we are explicitly requested to do so.
+ // Typically, when we are evaluating conditional breakpoints, we want to report any exception.
+ if (
+ this.shouldSkipAnyBreakpoint &&
+ !this.insideClientEvaluation?.reportExceptionsWhenBreaksAreDisabled
+ ) {
+ return undefined;
+ }
+
+ let willBeCaught = false;
+ for (let frame = youngestFrame; frame != null; frame = frame.older) {
+ if (frame.script.isInCatchScope(frame.offset)) {
+ willBeCaught = true;
+ break;
+ }
+ }
+
+ if (willBeCaught && this._options.ignoreCaughtExceptions) {
+ return undefined;
+ }
+
+ if (
+ this._handledFrameExceptions.has(youngestFrame) &&
+ this._handledFrameExceptions.get(youngestFrame) === value
+ ) {
+ return undefined;
+ }
+
+ // NS_ERROR_NO_INTERFACE exceptions are a special case in browser code,
+ // since they're almost always thrown by QueryInterface functions, and
+ // handled cleanly by native code.
+ if (!isWorker && value == Cr.NS_ERROR_NO_INTERFACE) {
+ return undefined;
+ }
+
+ // Don't pause on exceptions thrown while inside an evaluation being done on
+ // behalf of the client.
+ if (this.insideClientEvaluation) {
+ return undefined;
+ }
+
+ if (this.sourcesManager.isFrameBlackBoxed(youngestFrame)) {
+ return undefined;
+ }
+
+ // Now that we've decided to pause, ignore this exception if it's thrown by
+ // any older frames.
+ for (let frame = youngestFrame.older; frame != null; frame = frame.older) {
+ this._handledFrameExceptions.set(frame, value);
+ }
+
+ try {
+ const packet = this._paused(youngestFrame);
+ if (!packet) {
+ return undefined;
+ }
+
+ packet.why = {
+ type: PAUSE_REASONS.EXCEPTION,
+ exception: createValueGrip(value, this._pausePool, this.objectGrip),
+ };
+ this.emit("paused", packet);
+
+ this._nestedEventLoop.enter();
+ } catch (e) {
+ reportException("TA_onExceptionUnwind", e);
+ }
+
+ return undefined;
+ }
+
+ /**
+ * A function that the engine calls when a new script has been loaded.
+ *
+ * @param script Debugger.Script
+ * The source script that has been loaded into a debuggee compartment.
+ */
+ onNewScript(script) {
+ this._addSource(script.source);
+
+ this._maybeTrackFirstStatementBreakpoint(script);
+ }
+
+ /**
+ * A function called when there's a new source from a thread actor's sources.
+ * Emits `newSource` on the thread actor.
+ *
+ * @param {SourceActor} source
+ */
+ onNewSourceEvent(source) {
+ // When this target is supported by the Watcher Actor,
+ // and we listen to SOURCE, we avoid emitting the newSource RDP event
+ // as it would be duplicated with the Resource/watchResources API.
+ // Could probably be removed once bug 1680280 is fixed.
+ if (!this._shouldEmitNewSource) {
+ return;
+ }
+
+ // Bug 1516197: New sources are likely detected due to either user
+ // interaction on the page, or devtools requests sent to the server.
+ // We use executeSoon because we don't want to block those operations
+ // by sending packets in the middle of them.
+ DevToolsUtils.executeSoon(() => {
+ if (this.isDestroyed()) {
+ return;
+ }
+ this.emit("newSource", {
+ source: source.form(),
+ });
+ });
+ }
+
+ // API used by the Watcher Actor to disable the newSource events
+ // Could probably be removed once bug 1680280 is fixed.
+ _shouldEmitNewSource = true;
+ disableNewSourceEvents() {
+ this._shouldEmitNewSource = false;
+ }
+
+ /**
+ * Filtering function to filter out sources for which we don't want to notify/create
+ * source actors
+ *
+ * @param {Debugger.Source} source
+ * The source to accept or ignore
+ * @param Boolean
+ * True, if we want to create a source actor.
+ */
+ _acceptSource(source) {
+ // We have some spurious source created by ExtensionContent.sys.mjs when debugging tabs.
+ // These sources are internal stuff injected by WebExt codebase to implement content
+ // scripts. We can't easily ignore them from Debugger API, so ignore them
+ // when debugging a tab (i.e. browser-element). As we still want to debug them
+ // from the browser toolbox.
+ if (
+ this._parent.sessionContext.type == "browser-element" &&
+ source.url.endsWith("ExtensionContent.sys.mjs")
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Add the provided source to the server cache.
+ *
+ * @param aSource Debugger.Source
+ * The source that will be stored.
+ */
+ _addSource(source) {
+ if (!this._acceptSource(source)) {
+ return;
+ }
+
+ // Preloaded WebExtension content scripts may be cached internally by
+ // ExtensionContent.jsm and ThreadActor would ignore them on a page reload
+ // because it finds them in the _debuggerSourcesSeen WeakSet,
+ // and so we also need to be sure that there is still a source actor for the source.
+ let sourceActor;
+ if (
+ this._debuggerSourcesSeen.has(source) &&
+ this.sourcesManager.hasSourceActor(source)
+ ) {
+ sourceActor = this.sourcesManager.getSourceActor(source);
+ sourceActor.resetDebuggeeScripts();
+ } else {
+ sourceActor = this.sourcesManager.createSourceActor(source);
+ }
+
+ const sourceUrl = sourceActor.url;
+ if (this._onLoadBreakpointURLs.has(sourceUrl)) {
+ // Immediately set a breakpoint on first line
+ // (note that this is only used by `./mach xpcshell-test --jsdebugger`)
+ this.setBreakpoint({ sourceUrl, line: 1 }, {});
+ // But also query asynchronously the first really breakable line
+ // as the first may not be valid and won't break.
+ (async () => {
+ const [firstLine] = await sourceActor.getBreakableLines();
+ if (firstLine != 1) {
+ this.setBreakpoint({ sourceUrl, line: firstLine }, {});
+ }
+ })();
+ }
+
+ const bpActors = this.breakpointActorMap
+ .findActors()
+ .filter(
+ actor =>
+ actor.location.sourceUrl && actor.location.sourceUrl == sourceUrl
+ );
+
+ for (const actor of bpActors) {
+ sourceActor.applyBreakpoint(actor);
+ }
+
+ this._debuggerSourcesSeen.add(source);
+ }
+
+ /**
+ * Create a new source by refetching the specified URL and instantiating all
+ * sources that were found in the result.
+ *
+ * @param url The URL string to fetch.
+ * @param existingInlineSources The inline sources for the URL the debugger knows about
+ * already, and that we shouldn't re-create (only used when
+ * url content type is text/html).
+ */
+ async _resurrectSource(url, existingInlineSources) {
+ let { content, contentType, sourceMapURL } =
+ await this.sourcesManager.urlContents(
+ url,
+ /* partial */ false,
+ /* canUseCache */ true
+ );
+
+ // Newlines in all sources should be normalized. Do this with HTML content
+ // to simplify the comparisons below.
+ content = content.replace(/\r\n?|\u2028|\u2029/g, "\n");
+
+ if (contentType == "text/html") {
+ // HTML files can contain any number of inline sources. We have to find
+ // all the inline sources and their start line without running any of the
+ // scripts on the page. The approach used here is approximate.
+ if (!this._parent.window) {
+ return;
+ }
+
+ // Find the offsets in the HTML at which inline scripts might start.
+ const scriptTagMatches = content.matchAll(/<script[^>]*>/gi);
+ const scriptStartOffsets = [...scriptTagMatches].map(
+ rv => rv.index + rv[0].length
+ );
+
+ // Find the script tags in this HTML page by parsing a new document from
+ // the contentand looking for its script elements.
+ const document = new DOMParser().parseFromString(content, "text/html");
+
+ // For each inline source found, see if there is a start offset for what
+ // appears to be a script tag, whose contents match the inline source.
+ [...document.scripts].forEach(script => {
+ const text = script.innerText;
+
+ // We only want to handle inline scripts
+ if (script.src) {
+ return;
+ }
+
+ // Don't create source for empty script tag
+ if (!text.trim()) {
+ return;
+ }
+
+ const scriptStartOffsetIndex = scriptStartOffsets.findIndex(
+ offset => content.substring(offset, offset + text.length) == text
+ );
+ // Bail if we couldn't find the start offset for the script
+ if (scriptStartOffsetIndex == -1) {
+ return;
+ }
+
+ const scriptStartOffset = scriptStartOffsets[scriptStartOffsetIndex];
+ // Remove the offset from the array to mitigate any issue we might with scripts
+ // sharing the same text content.
+ scriptStartOffsets.splice(scriptStartOffsetIndex, 1);
+
+ const allLineBreaks = [
+ ...content.substring(0, scriptStartOffset).matchAll("\n"),
+ ];
+ const startLine = 1 + allLineBreaks.length;
+ // NOTE: Debugger.Source.prototype.startColumn is 1-based.
+ // Create 1-based column here for the following comparison,
+ // and also the createSource call below.
+ const startColumn =
+ 1 +
+ scriptStartOffset -
+ (allLineBreaks.length ? allLineBreaks.at(-1).index - 1 : 0);
+
+ // Don't create a source if we already found one for this script
+ if (
+ existingInlineSources.find(
+ source =>
+ source.startLine == startLine && source.startColumn == startColumn
+ )
+ ) {
+ return;
+ }
+
+ try {
+ const global = this.dbg.getDebuggees()[0];
+ // NOTE: Debugger.Object.prototype.createSource takes 1-based column.
+ this._addSource(
+ global.createSource({
+ text,
+ url,
+ startLine,
+ startColumn,
+ isScriptElement: true,
+ })
+ );
+ } catch (e) {
+ // Ignore parse errors.
+ }
+ });
+
+ // If no scripts were found, we might have an inaccurate content type and
+ // the file is actually JavaScript. Fall through and add the entire file
+ // as the source.
+ if (document.scripts.length) {
+ return;
+ }
+ }
+
+ // Other files should only contain javascript, so add the file contents as
+ // the source itself.
+ try {
+ const global = this.dbg.getDebuggees()[0];
+ this._addSource(
+ global.createSource({
+ text: content,
+ url,
+ startLine: 1,
+ sourceMapURL,
+ })
+ );
+ } catch (e) {
+ // Ignore parse errors.
+ }
+ }
+
+ dumpThread() {
+ return {
+ pauseOnExceptions: this._options.pauseOnExceptions,
+ ignoreCaughtExceptions: this._options.ignoreCaughtExceptions,
+ logEventBreakpoints: this._options.logEventBreakpoints,
+ skipBreakpoints: this.shouldSkipAnyBreakpoint,
+ breakpoints: this.breakpointActorMap.listKeys(),
+ };
+ }
+
+ // NOTE: dumpPools is defined in the Thread actor to avoid
+ // adding it to multiple target specs and actors.
+ dumpPools() {
+ return this.conn.dumpPools();
+ }
+
+ logLocation(prefix, frame) {
+ const loc = this.sourcesManager.getFrameLocation(frame);
+ dump(`${prefix} (${loc.line}, ${loc.column})\n`);
+ }
+}
+
+exports.ThreadActor = ThreadActor;
+
+/**
+ * Creates a PauseActor.
+ *
+ * PauseActors exist for the lifetime of a given debuggee pause. Used to
+ * scope pause-lifetime grips.
+ *
+ * @param {Pool} pool: The actor pool created for this pause.
+ */
+function PauseActor(pool) {
+ this.pool = pool;
+}
+
+PauseActor.prototype = {
+ typeName: "pause",
+};
+
+// Utility functions.
+
+/**
+ * Unwrap a global that is wrapped in a |Debugger.Object|, or if the global has
+ * become a dead object, return |undefined|.
+ *
+ * @param Debugger.Object wrappedGlobal
+ * The |Debugger.Object| which wraps a global.
+ *
+ * @returns {Object|undefined}
+ * Returns the unwrapped global object or |undefined| if unwrapping
+ * failed.
+ */
+exports.unwrapDebuggerObjectGlobal = wrappedGlobal => {
+ try {
+ // Because of bug 991399 we sometimes get nuked window references here. We
+ // just bail out in that case.
+ //
+ // Note that addon sandboxes have a DOMWindow as their prototype. So make
+ // sure that we can touch the prototype too (whatever it is), in case _it_
+ // is it a nuked window reference. We force stringification to make sure
+ // that any dead object proxies make themselves known.
+ const global = wrappedGlobal.unsafeDereference();
+ Object.getPrototypeOf(global) + "";
+ return global;
+ } catch (e) {
+ return undefined;
+ }
+};
diff --git a/devtools/server/actors/tracer.js b/devtools/server/actors/tracer.js
new file mode 100644
index 0000000000..028d084584
--- /dev/null
+++ b/devtools/server/actors/tracer.js
@@ -0,0 +1,502 @@
+/* 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";
+
+// Bug 1827382, as this module can be used from the worker thread,
+// the following JSM may be loaded by the worker loader until
+// we have proper support for ESM from workers.
+const {
+ startTracing,
+ stopTracing,
+ addTracingListener,
+ removeTracingListener,
+ NEXT_INTERACTION_MESSAGE,
+} = require("resource://devtools/server/tracer/tracer.jsm");
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const { tracerSpec } = require("resource://devtools/shared/specs/tracer.js");
+
+const { throttle } = require("resource://devtools/shared/throttle.js");
+
+const {
+ makeDebuggeeValue,
+ createValueGripForTarget,
+} = require("devtools/server/actors/object/utils");
+
+const {
+ TYPES,
+ getResourceWatcher,
+} = require("resource://devtools/server/actors/resources/index.js");
+const { JSTRACER_TRACE } = TYPES;
+
+loader.lazyRequireGetter(
+ this,
+ "GeckoProfileCollector",
+ "resource://devtools/server/actors/utils/gecko-profile-collector.js",
+ true
+);
+
+const LOG_METHODS = {
+ STDOUT: "stdout",
+ CONSOLE: "console",
+ PROFILER: "profiler",
+};
+exports.LOG_METHODS = LOG_METHODS;
+const VALID_LOG_METHODS = Object.values(LOG_METHODS);
+
+const CONSOLE_THROTTLING_DELAY = 250;
+
+class TracerActor extends Actor {
+ constructor(conn, targetActor) {
+ super(conn, tracerSpec);
+ this.targetActor = targetActor;
+ this.sourcesManager = this.targetActor.sourcesManager;
+
+ this.throttledTraces = [];
+ // On workers, we don't have access to setTimeout and can't have throttling
+ this.throttleEmitTraces = isWorker
+ ? this.flushTraces.bind(this)
+ : throttle(this.flushTraces.bind(this), CONSOLE_THROTTLING_DELAY);
+
+ this.geckoProfileCollector = new GeckoProfileCollector();
+ }
+
+ destroy() {
+ this.stopTracing();
+ }
+
+ getLogMethod() {
+ return this.logMethod;
+ }
+
+ /**
+ * Toggle tracing JavaScript.
+ * Meant for the WebConsole command in order to pass advanced
+ * configuration directly to JavaScriptTracer class.
+ *
+ * @param {Object} options
+ * Options used to configure JavaScriptTracer.
+ * See `JavaScriptTracer.startTracing`.
+ * @return {Boolean}
+ * True if the tracer starts, or false if it was stopped.
+ */
+ toggleTracing(options) {
+ if (!this.tracingListener) {
+ this.startTracing(options);
+ return true;
+ }
+ this.stopTracing();
+ return false;
+ }
+
+ /**
+ * Start tracing.
+ *
+ * @param {Object} options
+ * Options used to configure JavaScriptTracer.
+ * See `JavaScriptTracer.startTracing`.
+ */
+ startTracing(options = {}) {
+ if (options.logMethod && !VALID_LOG_METHODS.includes(options.logMethod)) {
+ throw new Error(
+ `Invalid log method '${options.logMethod}'. Only supports: ${VALID_LOG_METHODS}`
+ );
+ }
+ if (options.prefix && typeof options.prefix != "string") {
+ throw new Error("Invalid prefix, only support string type");
+ }
+ if (options.maxDepth && typeof options.maxDepth != "number") {
+ throw new Error("Invalid max-depth, only support numbers");
+ }
+ if (options.maxRecords && typeof options.maxRecords != "number") {
+ throw new Error("Invalid max-records, only support numbers");
+ }
+
+ // When tracing on next user interaction is enabled,
+ // disable logging from workers as this makes the tracer work
+ // against visible documents and is actived per document thread.
+ if (options.traceOnNextInteraction && isWorker) {
+ return;
+ }
+
+ this.logMethod = options.logMethod || LOG_METHODS.STDOUT;
+
+ if (this.logMethod == LOG_METHODS.PROFILER) {
+ this.geckoProfileCollector.start();
+ }
+
+ this.tracingListener = {
+ onTracingFrame: this.onTracingFrame.bind(this),
+ onTracingFrameExit: this.onTracingFrameExit.bind(this),
+ onTracingInfiniteLoop: this.onTracingInfiniteLoop.bind(this),
+ onTracingToggled: this.onTracingToggled.bind(this),
+ onTracingPending: this.onTracingPending.bind(this),
+ };
+ addTracingListener(this.tracingListener);
+ this.traceValues = !!options.traceValues;
+ startTracing({
+ global: this.targetActor.window || this.targetActor.workerGlobal,
+ prefix: options.prefix || "",
+ // Enable receiving the `currentDOMEvent` being passed to `onTracingFrame`
+ traceDOMEvents: true,
+ // Enable tracing function arguments as well as returned values
+ traceValues: !!options.traceValues,
+ // Enable tracing only on next user interaction
+ traceOnNextInteraction: !!options.traceOnNextInteraction,
+ // Notify about frame exit / function call returning
+ traceFunctionReturn: !!options.traceFunctionReturn,
+ // Ignore frames beyond the given depth
+ maxDepth: options.maxDepth,
+ // Stop the tracing after a number of top level frames
+ maxRecords: options.maxRecords,
+ });
+ }
+
+ stopTracing() {
+ if (!this.tracingListener) {
+ return;
+ }
+ // Remove before stopping to prevent receiving the stop notification
+ removeTracingListener(this.tracingListener);
+ this.tracingListener = null;
+
+ stopTracing();
+ this.logMethod = null;
+ }
+
+ /**
+ * Queried by THREAD_STATE watcher to send the gecko profiler data
+ * as part of THREAD STATE "stop" resource.
+ *
+ * @return {Object} Gecko profiler profile object.
+ */
+ getProfile() {
+ const profile = this.geckoProfileCollector.stop();
+ // We only open the profile if it contains samples, otherwise it can crash the frontend.
+ if (profile.threads[0].samples.data.length) {
+ return profile;
+ }
+ return null;
+ }
+
+ /**
+ * Be notified by the underlying JavaScriptTracer class
+ * in case it stops by itself, instead of being stopped when the Actor's stopTracing
+ * method is called by the user.
+ *
+ * @param {Boolean} enabled
+ * True if the tracer starts tracing, false it it stops.
+ * @param {String} reason
+ * Optional string to justify why the tracer stopped.
+ * @return {Boolean}
+ * Return true, if the JavaScriptTracer should log a message to stdout.
+ */
+ onTracingToggled(enabled, reason) {
+ // stopTracing will clear `logMethod`, so compute this before calling it.
+ const shouldLogToStdout = this.logMethod == LOG_METHODS.STDOUT;
+
+ if (!enabled) {
+ this.stopTracing();
+ }
+ return shouldLogToStdout;
+ }
+
+ /**
+ * Called when "trace on next user interaction" is enabled, to notify the user
+ * that the tracer is initialized but waiting for the user first input.
+ */
+ onTracingPending() {
+ // Delegate to JavaScriptTracer to log to stdout
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ return true;
+ }
+
+ if (this.logMethod == LOG_METHODS.CONSOLE) {
+ const consoleMessageWatcher = getResourceWatcher(
+ this.targetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (consoleMessageWatcher) {
+ consoleMessageWatcher.emitMessages([
+ {
+ arguments: [NEXT_INTERACTION_MESSAGE],
+ styles: [],
+ level: "jstracer",
+ chromeContext: false,
+ timeStamp: ChromeUtils.dateNow(),
+ },
+ ]);
+ }
+ return false;
+ }
+ return false;
+ }
+
+ onTracingInfiniteLoop() {
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ return true;
+ }
+ if (this.logMethod == LOG_METHODS.PROFILER) {
+ this.geckoProfileCollector.stop();
+ return true;
+ }
+ const consoleMessageWatcher = getResourceWatcher(
+ this.targetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (!consoleMessageWatcher) {
+ return true;
+ }
+
+ const message =
+ "Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!";
+ consoleMessageWatcher.emitMessages([
+ {
+ arguments: [message],
+ styles: [],
+ level: "jstracer",
+ chromeContext: false,
+ timeStamp: ChromeUtils.dateNow(),
+ },
+ ]);
+
+ return false;
+ }
+
+ /**
+ * Called by JavaScriptTracer class when a new JavaScript frame is executed.
+ *
+ * @param {Number} frameId
+ * Unique identifier for the current frame.
+ * This should match a frame notified via onTracingFrameExit.
+ * @param {Debugger.Frame} frame
+ * A descriptor object for the JavaScript frame.
+ * @param {Number} depth
+ * Represents the depth of the frame in the call stack.
+ * @param {String} formatedDisplayName
+ * A human readable name for the current frame.
+ * @param {String} prefix
+ * A string to be displayed as a prefix of any logged frame.
+ * @param {String} currentDOMEvent
+ * If this is a top level frame (depth==0), and we are currently processing
+ * a DOM Event, this will refer to the name of that DOM Event.
+ * Note that it may also refer to setTimeout and setTimeout callback calls.
+ * @return {Boolean}
+ * Return true, if the JavaScriptTracer should log the frame to stdout.
+ */
+ onTracingFrame({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix,
+ currentDOMEvent,
+ }) {
+ const { script } = frame;
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
+ const url = script.source.url;
+
+ // NOTE: Debugger.Script.prototype.getOffsetMetadata returns
+ // columnNumber in 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ // Ignore blackboxed sources
+ if (
+ this.sourcesManager.isBlackBoxed(
+ url,
+ lineNumber,
+ columnNumber - columnBase
+ )
+ ) {
+ return false;
+ }
+
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ // By returning true, we let JavaScriptTracer class log the message to stdout.
+ return true;
+ }
+
+ if (this.logMethod == LOG_METHODS.CONSOLE) {
+ // We may receive the currently processed DOM event (if this relates to one).
+ // In this case, log a preliminary message, which looks different to highlight it.
+ if (currentDOMEvent && depth == 0) {
+ // Create a JSTRACER_TRACE resource with a slightly different shape
+ this.throttledTraces.push({
+ resourceType: JSTRACER_TRACE,
+ prefix,
+ timeStamp: ChromeUtils.dateNow(),
+
+ eventName: currentDOMEvent,
+ });
+ }
+
+ let args = undefined;
+ // Log arguments, but only when this feature is enabled as it introduce
+ // some significant overhead in perf as well as memory as it may hold the objects in memory.
+ // Also prevent trying to log function call arguments if we aren't logging a frame
+ // with arguments (e.g. Debugger evaluation frames, when executing from the console)
+ if (this.traceValues && frame.arguments) {
+ args = [];
+ for (let arg of frame.arguments) {
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (arg?.unsafeDereference) {
+ arg = arg.unsafeDereference();
+ }
+ // Instantiate a object actor so that the tools can easily inspect these objects
+ const dbgObj = makeDebuggeeValue(this.targetActor, arg);
+ args.push(createValueGripForTarget(this.targetActor, dbgObj));
+ }
+ }
+
+ // Create a message object that fits Console Message Watcher expectations
+ this.throttledTraces.push({
+ resourceType: JSTRACER_TRACE,
+ prefix,
+ timeStamp: ChromeUtils.dateNow(),
+
+ depth,
+ implementation: frame.implementation,
+ displayName: formatedDisplayName,
+ filename: url,
+ lineNumber,
+ columnNumber: columnNumber - columnBase,
+ sourceId: script.source.id,
+ args,
+ });
+ this.throttleEmitTraces();
+ } else if (this.logMethod == LOG_METHODS.PROFILER) {
+ this.geckoProfileCollector.addSample(
+ {
+ // formatedDisplayName has a lambda at the beginning, remove it.
+ name: formatedDisplayName.replace("λ ", ""),
+ url,
+ lineNumber,
+ columnNumber,
+ category: frame.implementation,
+ },
+ depth
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Called by JavaScriptTracer class when a JavaScript frame exits (i.e. a function returns or throw).
+ *
+ * @param {Object} options
+ * @param {Number} options.frameId
+ * Unique identifier for the current frame.
+ * This should match a frame notified via onTracingFrame.
+ * @param {Debugger.Frame} options.frame
+ * A descriptor object for the JavaScript frame.
+ * @param {Number} options.depth
+ * Represents the depth of the frame in the call stack.
+ * @param {String} options.formatedDisplayName
+ * A human readable name for the current frame.
+ * @param {String} options.prefix
+ * A string to be displayed as a prefix of any logged frame.
+ * @param {String} options.why
+ * A string to explain why the function stopped.
+ * See tracer.jsm's FRAME_EXIT_REASONS.
+ * @param {Debugger.Object|primitive} options.rv
+ * The returned value. It can be the returned value, or the thrown exception.
+ * It is either a primitive object, otherwise it is a Debugger.Object for any other JS Object type.
+ * @return {Boolean}
+ * Return true, if the JavaScriptTracer should log the frame to stdout.
+ */
+ onTracingFrameExit({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix,
+ why,
+ rv,
+ }) {
+ const { script } = frame;
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
+ const url = script.source.url;
+
+ // NOTE: Debugger.Script.prototype.getOffsetMetadata returns
+ // columnNumber in 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+
+ // Ignore blackboxed sources
+ if (
+ this.sourcesManager.isBlackBoxed(
+ url,
+ lineNumber,
+ columnNumber - columnBase
+ )
+ ) {
+ return false;
+ }
+
+ if (this.logMethod == LOG_METHODS.STDOUT) {
+ // By returning true, we let JavaScriptTracer class log the message to stdout.
+ return true;
+ }
+
+ if (this.logMethod == LOG_METHODS.CONSOLE) {
+ let returnedValue = undefined;
+ // Log arguments, but only when this feature is enabled as it introduce
+ // some significant overhead in perf as well as memory as it may hold the objects in memory.
+ if (this.traceValues) {
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (rv?.unsafeDereference) {
+ rv = rv.unsafeDereference();
+ }
+ // Instantiate a object actor so that the tools can easily inspect these objects
+ const dbgObj = makeDebuggeeValue(this.targetActor, rv);
+ returnedValue = createValueGripForTarget(this.targetActor, dbgObj);
+ }
+
+ // Create a message object that fits Console Message Watcher expectations
+ this.throttledTraces.push({
+ resourceType: JSTRACER_TRACE,
+ prefix,
+ timeStamp: ChromeUtils.dateNow(),
+
+ depth,
+ displayName: formatedDisplayName,
+ filename: url,
+ lineNumber,
+ columnNumber: columnNumber - columnBase,
+ sourceId: script.source.id,
+
+ relatedTraceId: frameId,
+ returnedValue,
+ why,
+ });
+ this.throttleEmitTraces();
+ } else if (this.logMethod == LOG_METHODS.PROFILER) {
+ // For now, the profiler doesn't use this.
+ }
+
+ return false;
+ }
+
+ /**
+ * This method is throttled and will notify all pending traces to be logged in the console
+ * via the console message watcher.
+ */
+ flushTraces() {
+ const traceWatcher = getResourceWatcher(this.targetActor, JSTRACER_TRACE);
+ // Ignore the request if the frontend isn't listening to traces for that target.
+ if (!traceWatcher) {
+ return;
+ }
+ const traces = this.throttledTraces;
+ this.throttledTraces = [];
+
+ traceWatcher.emitTraces(traces);
+ }
+}
+exports.TracerActor = TracerActor;
diff --git a/devtools/server/actors/utils/accessibility.js b/devtools/server/actors/utils/accessibility.js
new file mode 100644
index 0000000000..ee8ee9ccd0
--- /dev/null
+++ b/devtools/server/actors/utils/accessibility.js
@@ -0,0 +1,103 @@
+/* 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,
+ ["loadSheet", "removeSheet"],
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+
+// Highlighter style used for preventing transitions and applying transparency
+// when calculating colour contrast.
+const HIGHLIGHTER_STYLES_SHEET = `data:text/css;charset=utf-8,
+* {
+ transition: initial !important;
+}
+
+:-moz-devtools-highlighted {
+ color: transparent !important;
+ text-shadow: none !important;
+}`;
+
+/**
+ * Helper function that determines if nsIAccessible object is in defunct state.
+ *
+ * @param {nsIAccessible} accessible
+ * object to be tested.
+ * @return {Boolean}
+ * True if accessible object is defunct, false otherwise.
+ */
+function isDefunct(accessible) {
+ // If accessibility is disabled, safely assume that the accessible object is
+ // now dead.
+ if (!Services.appinfo.accessibilityEnabled) {
+ return true;
+ }
+
+ let defunct = false;
+
+ try {
+ const extraState = {};
+ accessible.getState({}, extraState);
+ // extraState.value is a bitmask. We are applying bitwise AND to mask out
+ // irrelevant states.
+ defunct = !!(extraState.value & Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT);
+ } catch (e) {
+ defunct = true;
+ }
+
+ return defunct;
+}
+
+/**
+ * Load highlighter style sheet used for preventing transitions and
+ * applying transparency when calculating colour contrast.
+ *
+ * @param {Window} win
+ * Window where highlighting happens.
+ */
+function loadSheetForBackgroundCalculation(win) {
+ loadSheet(win, HIGHLIGHTER_STYLES_SHEET);
+}
+
+/**
+ * Unload highlighter style sheet used for preventing transitions
+ * and applying transparency when calculating colour contrast.
+ *
+ * @param {Window} win
+ * Window where highlighting was happenning.
+ */
+function removeSheetForBackgroundCalculation(win) {
+ removeSheet(win, HIGHLIGHTER_STYLES_SHEET);
+}
+
+/**
+ * Get role attribute for an accessible object if specified for its
+ * corresponding DOMNode.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible for which to determine its role attribute value.
+ *
+ * @returns {null|String}
+ * Role attribute value if specified.
+ */
+function getAriaRoles(accessible) {
+ try {
+ return accessible.attributes.getStringProperty("xml-roles");
+ } catch (e) {
+ // No xml-roles. nsPersistentProperties throws if the attribute for a key
+ // is not found.
+ }
+
+ return null;
+}
+
+exports.getAriaRoles = getAriaRoles;
+exports.isDefunct = isDefunct;
+exports.loadSheetForBackgroundCalculation = loadSheetForBackgroundCalculation;
+exports.removeSheetForBackgroundCalculation =
+ removeSheetForBackgroundCalculation;
diff --git a/devtools/server/actors/utils/actor-registry.js b/devtools/server/actors/utils/actor-registry.js
new file mode 100644
index 0000000000..ae88c38c6b
--- /dev/null
+++ b/devtools/server/actors/utils/actor-registry.js
@@ -0,0 +1,418 @@
+/* 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";
+
+var gRegisteredModules = Object.create(null);
+
+const ActorRegistry = {
+ // Map of global actor names to actor constructors.
+ globalActorFactories: {},
+ // Map of target-scoped actor names to actor constructors.
+ targetScopedActorFactories: {},
+ init(connections) {
+ this._connections = connections;
+ },
+
+ /**
+ * Register a CommonJS module with the devtools server.
+ * @param id string
+ * The ID of a CommonJS module.
+ * The actor is going to be registered immediately, but loaded only
+ * when a client starts sending packets to an actor with the same id.
+ *
+ * @param options object
+ * An object with 3 mandatory attributes:
+ * - prefix (string):
+ * The prefix of an actor is used to compute:
+ * - the `actorID` of each new actor instance (ex: prefix1). (See Pool.manage)
+ * - the actor name in the listTabs request. Sending a listTabs
+ * request to the root actor returns actor IDs. IDs are in
+ * dictionaries, with actor names as keys and actor IDs as values.
+ * The actor name is the prefix to which the "Actor" string is
+ * appended. So for an actor with the `console` prefix, the actor
+ * name will be `consoleActor`.
+ * - constructor (string):
+ * the name of the exported symbol to be used as the actor
+ * constructor.
+ * - type (a dictionary of booleans with following attribute names):
+ * - "global"
+ * registers a global actor instance, if true.
+ * A global actor has the root actor as its parent.
+ * - "target"
+ * registers a target-scoped actor instance, if true.
+ * A new actor will be created for each target, such as a tab.
+ */
+ registerModule(id, options) {
+ if (id in gRegisteredModules) {
+ return;
+ }
+
+ if (!options) {
+ throw new Error(
+ "ActorRegistry.registerModule requires an options argument"
+ );
+ }
+ const { prefix, constructor, type } = options;
+ if (typeof prefix !== "string") {
+ throw new Error(
+ `Lazy actor definition for '${id}' requires a string ` +
+ `'prefix' option.`
+ );
+ }
+ if (typeof constructor !== "string") {
+ throw new Error(
+ `Lazy actor definition for '${id}' requires a string ` +
+ `'constructor' option.`
+ );
+ }
+ if (!("global" in type) && !("target" in type)) {
+ throw new Error(
+ `Lazy actor definition for '${id}' requires a dictionary ` +
+ `'type' option whose attributes can be 'global' or 'target'.`
+ );
+ }
+ const name = prefix + "Actor";
+ const mod = {
+ id,
+ prefix,
+ constructorName: constructor,
+ type,
+ globalActor: type.global,
+ targetScopedActor: type.target,
+ };
+ gRegisteredModules[id] = mod;
+ if (mod.targetScopedActor) {
+ this.addTargetScopedActor(mod, name);
+ }
+ if (mod.globalActor) {
+ this.addGlobalActor(mod, name);
+ }
+ },
+
+ /**
+ * Unregister a previously-loaded CommonJS module from the devtools server.
+ */
+ unregisterModule(id) {
+ const mod = gRegisteredModules[id];
+ if (!mod) {
+ throw new Error(
+ "Tried to unregister a module that was not previously registered."
+ );
+ }
+
+ // Lazy actors
+ if (mod.targetScopedActor) {
+ this.removeTargetScopedActor(mod);
+ }
+ if (mod.globalActor) {
+ this.removeGlobalActor(mod);
+ }
+
+ delete gRegisteredModules[id];
+ },
+
+ /**
+ * Install Firefox-specific actors.
+ *
+ * /!\ Be careful when adding a new actor, especially global actors.
+ * Any new global actor will be exposed and returned by the root actor.
+ */
+ addBrowserActors() {
+ this.registerModule("devtools/server/actors/preference", {
+ prefix: "preference",
+ constructor: "PreferenceActor",
+ type: { global: true },
+ });
+ this.registerModule("devtools/server/actors/addon/addons", {
+ prefix: "addons",
+ constructor: "AddonsActor",
+ type: { global: true },
+ });
+ this.registerModule("devtools/server/actors/device", {
+ prefix: "device",
+ constructor: "DeviceActor",
+ type: { global: true },
+ });
+ this.registerModule("devtools/server/actors/heap-snapshot-file", {
+ prefix: "heapSnapshotFile",
+ constructor: "HeapSnapshotFileActor",
+ type: { global: true },
+ });
+ // Always register this as a global module, even while there is a pref turning
+ // on and off the other performance actor. This actor shouldn't conflict with
+ // the other one. These are also lazily loaded so there shouldn't be a performance
+ // impact.
+ this.registerModule("devtools/server/actors/perf", {
+ prefix: "perf",
+ constructor: "PerfActor",
+ type: { global: true },
+ });
+ /**
+ * Always register parent accessibility actor as a global module. This
+ * actor is responsible for all top level accessibility actor functionality
+ * that relies on the parent process.
+ */
+ this.registerModule(
+ "devtools/server/actors/accessibility/parent-accessibility",
+ {
+ prefix: "parentAccessibility",
+ constructor: "ParentAccessibilityActor",
+ type: { global: true },
+ }
+ );
+
+ this.registerModule("devtools/server/actors/screenshot", {
+ prefix: "screenshot",
+ constructor: "ScreenshotActor",
+ type: { global: true },
+ });
+ },
+
+ /**
+ * Install target-scoped actors.
+ */
+ addTargetScopedActors() {
+ this.registerModule("devtools/server/actors/webconsole", {
+ prefix: "console",
+ constructor: "WebConsoleActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/inspector/inspector", {
+ prefix: "inspector",
+ constructor: "InspectorActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/style-sheets", {
+ prefix: "styleSheets",
+ constructor: "StyleSheetsActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/memory", {
+ prefix: "memory",
+ constructor: "MemoryActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/reflow", {
+ prefix: "reflow",
+ constructor: "ReflowActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/css-properties", {
+ prefix: "cssProperties",
+ constructor: "CssPropertiesActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/animation", {
+ prefix: "animations",
+ constructor: "AnimationsActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/emulation/responsive", {
+ prefix: "responsive",
+ constructor: "ResponsiveActor",
+ type: { target: true },
+ });
+ this.registerModule(
+ "devtools/server/actors/addon/webextension-inspected-window",
+ {
+ prefix: "webExtensionInspectedWindow",
+ constructor: "WebExtensionInspectedWindowActor",
+ type: { target: true },
+ }
+ );
+ this.registerModule("devtools/server/actors/accessibility/accessibility", {
+ prefix: "accessibility",
+ constructor: "AccessibilityActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/changes", {
+ prefix: "changes",
+ constructor: "ChangesActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/manifest", {
+ prefix: "manifest",
+ constructor: "ManifestActor",
+ type: { target: true },
+ });
+ this.registerModule(
+ "devtools/server/actors/network-monitor/network-content",
+ {
+ prefix: "networkContent",
+ constructor: "NetworkContentActor",
+ type: { target: true },
+ }
+ );
+ this.registerModule("devtools/server/actors/screenshot-content", {
+ prefix: "screenshotContent",
+ constructor: "ScreenshotContentActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/tracer", {
+ prefix: "tracer",
+ constructor: "TracerActor",
+ type: { target: true },
+ });
+ this.registerModule("devtools/server/actors/objects-manager", {
+ prefix: "objectsManager",
+ constructor: "ObjectsManagerActor",
+ type: { target: true },
+ });
+ },
+
+ /**
+ * Registers handlers for new target-scoped request types defined dynamically.
+ *
+ * Note that the name of the request type is not allowed to clash with existing protocol
+ * packet properties, like 'title', 'url' or 'actor', since that would break the protocol.
+ *
+ * @param options object
+ * - constructorName: (required)
+ * name of actor constructor, which is also used when removing the actor.
+ * One of the following:
+ * - id:
+ * module ID that contains the actor
+ * - constructorFun:
+ * a function to construct the actor
+ * @param name string
+ * The name of the new request type.
+ */
+ addTargetScopedActor(options, name) {
+ if (!name) {
+ throw Error("addTargetScopedActor requires the `name` argument");
+ }
+ if (["title", "url", "actor"].includes(name)) {
+ throw Error(name + " is not allowed");
+ }
+ if (this.targetScopedActorFactories.hasOwnProperty(name)) {
+ throw Error(name + " already exists");
+ }
+ this.targetScopedActorFactories[name] = { options, name };
+ },
+
+ /**
+ * Unregisters the handler for the specified target-scoped request type.
+ *
+ * When unregistering an existing target-scoped actor, we remove the actor factory as
+ * well as all existing instances of the actor.
+ *
+ * @param actor object, string
+ * In case of object:
+ * The `actor` object being given to related addTargetScopedActor call.
+ * In case of string:
+ * The `name` string being given to related addTargetScopedActor call.
+ */
+ removeTargetScopedActor(actorOrName) {
+ let name;
+ if (typeof actorOrName == "string") {
+ name = actorOrName;
+ } else {
+ const actor = actorOrName;
+ for (const factoryName in this.targetScopedActorFactories) {
+ const handler = this.targetScopedActorFactories[factoryName];
+ if (
+ handler.options.constructorName == actor.name ||
+ handler.options.id == actor.id
+ ) {
+ name = factoryName;
+ break;
+ }
+ }
+ }
+ if (!name) {
+ return;
+ }
+ delete this.targetScopedActorFactories[name];
+ for (const connID of Object.getOwnPropertyNames(this._connections)) {
+ // DevToolsServerConnection in child process don't have rootActor
+ if (this._connections[connID].rootActor) {
+ this._connections[connID].rootActor.removeActorByName(name);
+ }
+ }
+ },
+
+ /**
+ * Registers handlers for new browser-scoped request types defined dynamically.
+ *
+ * Note that the name of the request type is not allowed to clash with existing protocol
+ * packet properties, like 'from', 'tabs' or 'selected', since that would break the protocol.
+ *
+ * @param options object
+ * - constructorName: (required)
+ * name of actor constructor, which is also used when removing the actor.
+ * One of the following:
+ * - id:
+ * module ID that contains the actor
+ * - constructorFun:
+ * a function to construct the actor
+ * @param name string
+ * The name of the new request type.
+ */
+ addGlobalActor(options, name) {
+ if (!name) {
+ throw Error("addGlobalActor requires the `name` argument");
+ }
+ if (["from", "tabs", "selected"].includes(name)) {
+ throw Error(name + " is not allowed");
+ }
+ if (this.globalActorFactories.hasOwnProperty(name)) {
+ throw Error(name + " already exists");
+ }
+ this.globalActorFactories[name] = { options, name };
+ },
+
+ /**
+ * Unregisters the handler for the specified browser-scoped request type.
+ *
+ * When unregistering an existing global actor, we remove the actor factory as well as
+ * all existing instances of the actor.
+ *
+ * @param actor object, string
+ * In case of object:
+ * The `actor` object being given to related addGlobalActor call.
+ * In case of string:
+ * The `name` string being given to related addGlobalActor call.
+ */
+ removeGlobalActor(actorOrName) {
+ let name;
+ if (typeof actorOrName == "string") {
+ name = actorOrName;
+ } else {
+ const actor = actorOrName;
+ for (const factoryName in this.globalActorFactories) {
+ const handler = this.globalActorFactories[factoryName];
+ if (
+ handler.options.constructorName == actor.name ||
+ handler.options.id == actor.id
+ ) {
+ name = factoryName;
+ break;
+ }
+ }
+ }
+ if (!name) {
+ return;
+ }
+ delete this.globalActorFactories[name];
+ for (const connID of Object.getOwnPropertyNames(this._connections)) {
+ // DevToolsServerConnection in child process don't have rootActor
+ if (this._connections[connID].rootActor) {
+ this._connections[connID].rootActor.removeActorByName(name);
+ }
+ }
+ },
+
+ destroy() {
+ for (const id of Object.getOwnPropertyNames(gRegisteredModules)) {
+ this.unregisterModule(id);
+ }
+ gRegisteredModules = Object.create(null);
+
+ this.globalActorFactories = {};
+ this.targetScopedActorFactories = {};
+ },
+};
+
+exports.ActorRegistry = ActorRegistry;
diff --git a/devtools/server/actors/utils/breakpoint-actor-map.js b/devtools/server/actors/utils/breakpoint-actor-map.js
new file mode 100644
index 0000000000..cce32b2833
--- /dev/null
+++ b/devtools/server/actors/utils/breakpoint-actor-map.js
@@ -0,0 +1,84 @@
+/* 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 {
+ BreakpointActor,
+} = require("resource://devtools/server/actors/breakpoint.js");
+
+/**
+ * A BreakpointActorMap is a map from locations to instances of BreakpointActor.
+ */
+class BreakpointActorMap {
+ constructor(threadActor) {
+ this._threadActor = threadActor;
+ this._actors = {};
+ }
+
+ // Get the key in the _actors table for a given breakpoint location.
+ // See also duplicate code in commands.js :(
+ _locationKey(location) {
+ const { sourceUrl, sourceId, line, column } = location;
+ return `${sourceUrl}:${sourceId}:${line}:${column}`;
+ }
+
+ /**
+ * Return all BreakpointActors in this BreakpointActorMap.
+ */
+ findActors() {
+ return Object.values(this._actors);
+ }
+
+ listKeys() {
+ return Object.keys(this._actors);
+ }
+
+ /**
+ * Return the BreakpointActor at the given location in this
+ * BreakpointActorMap.
+ *
+ * @param BreakpointLocation location
+ * The location for which the BreakpointActor should be returned.
+ *
+ * @returns BreakpointActor actor
+ * The BreakpointActor at the given location.
+ */
+ getOrCreateBreakpointActor(location) {
+ const key = this._locationKey(location);
+ if (!this._actors[key]) {
+ this._actors[key] = new BreakpointActor(this._threadActor, location);
+ }
+ return this._actors[key];
+ }
+
+ get(location) {
+ const key = this._locationKey(location);
+ return this._actors[key];
+ }
+
+ /**
+ * Delete the BreakpointActor from the given location in this
+ * BreakpointActorMap.
+ *
+ * @param BreakpointLocation location
+ * The location from which the BreakpointActor should be deleted.
+ */
+ deleteActor(location) {
+ const key = this._locationKey(location);
+ delete this._actors[key];
+ }
+
+ /**
+ * Unregister all currently active breakpoints.
+ */
+ removeAllBreakpoints() {
+ for (const bpActor of Object.values(this._actors)) {
+ bpActor.removeScripts();
+ }
+ this._actors = {};
+ }
+}
+
+exports.BreakpointActorMap = BreakpointActorMap;
diff --git a/devtools/server/actors/utils/capture-screenshot.js b/devtools/server/actors/utils/capture-screenshot.js
new file mode 100644
index 0000000000..e7b46620b2
--- /dev/null
+++ b/devtools/server/actors/utils/capture-screenshot.js
@@ -0,0 +1,200 @@
+/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+
+const CONTAINER_FLASHING_DURATION = 500;
+const STRINGS_URI = "devtools/shared/locales/screenshot.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
+
+// These values are used to truncate the resulting image if the captured area is bigger.
+// This is to avoid failing to produce a screenshot at all.
+// It is recommended to keep these values in sync with the corresponding screenshots addon
+// values in /browser/extensions/screenshots/selector/uicontrol.js
+const MAX_IMAGE_WIDTH = 10000;
+const MAX_IMAGE_HEIGHT = 10000;
+
+/**
+ * This function is called to simulate camera effects
+ * @param {BrowsingContext} browsingContext: The browsing context associated with the
+ * browser element we want to animate.
+ */
+function simulateCameraFlash(browsingContext) {
+ // If there's no topFrameElement (it can happen if the screenshot is taken from the
+ // browser toolbox), use the top chrome window document element.
+ const node =
+ browsingContext.topFrameElement ||
+ browsingContext.topChromeWindow.document.documentElement;
+
+ if (!node) {
+ console.error(
+ "Can't find an element to play the camera flash animation on for the following browsing context:",
+ browsingContext
+ );
+ return;
+ }
+
+ // Don't take a screenshot if the user prefers reduced motion.
+ if (node.ownerGlobal.matchMedia("(prefers-reduced-motion)").matches) {
+ return;
+ }
+
+ node.animate([{ opacity: 0 }, { opacity: 1 }], {
+ duration: CONTAINER_FLASHING_DURATION,
+ });
+}
+
+/**
+ * Take a screenshot of a browser element given its browsingContext.
+ *
+ * @param {Object} args
+ * @param {Number} args.delay: Number of seconds to wait before taking the screenshot
+ * @param {Object|null} args.rect: Object with left, top, width and height properties
+ * representing the rect **inside the browser element** that should
+ * be rendered. If null, the current viewport of the element will be rendered.
+ * @param {Boolean} args.fullpage: Should the screenshot be the height of the whole page
+ * @param {String} args.filename: Expected filename for the screenshot
+ * @param {Number} args.snapshotScale: Scale that will be used by `drawSnapshot` to take the screenshot.
+ * ⚠️ Note that the scale might be decreased if the resulting image would
+ * be too big to draw safely. A warning message will be returned if that's
+ * the case.
+ * @param {Number} args.fileScale: Scale of the exported file. Defaults to args.snapshotScale.
+ * @param {Boolean} args.disableFlash: Set to true to disable the flash animation when the
+ * screenshot is taken.
+ * @param {BrowsingContext} browsingContext
+ * @returns {Object} object with the following properties:
+ * - data {String}: The dataURL representing the screenshot
+ * - height {Number}: Height of the resulting screenshot
+ * - width {Number}: Width of the resulting screenshot
+ * - filename {String}: Filename of the resulting screenshot
+ * - messages {Array<Object{text, level}>}: An array of object representing the
+ * different messages and their level that should be displayed to the user.
+ */
+async function captureScreenshot(args, browsingContext) {
+ const messages = [];
+
+ let filename = getFilename(args.filename);
+
+ if (args.fullpage) {
+ filename = filename.replace(".png", "-fullpage.png");
+ }
+
+ let { left, top, width, height } = args.rect || {};
+
+ // Truncate the width and height if necessary.
+ if (width && (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT)) {
+ width = Math.min(width, MAX_IMAGE_WIDTH);
+ height = Math.min(height, MAX_IMAGE_HEIGHT);
+ messages.push({
+ level: "warn",
+ text: L10N.getFormatStr("screenshotTruncationWarning", width, height),
+ });
+ }
+
+ let rect = null;
+ if (args.rect) {
+ rect = new globalThis.DOMRect(
+ Math.round(left),
+ Math.round(top),
+ Math.round(width),
+ Math.round(height)
+ );
+ }
+
+ const document = browsingContext.topChromeWindow.document;
+ const canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+
+ const drawToCanvas = async actualRatio => {
+ // Even after decreasing width, height and ratio, there may still be cases where the
+ // hardware fails at creating the image. Let's catch this so we can at least show an
+ // error message to the user.
+ try {
+ const snapshot = await browsingContext.currentWindowGlobal.drawSnapshot(
+ rect,
+ actualRatio,
+ "rgb(255,255,255)",
+ args.fullpage
+ );
+
+ const fileScale = args.fileScale || actualRatio;
+ const renderingWidth = (snapshot.width / actualRatio) * fileScale;
+ const renderingHeight = (snapshot.height / actualRatio) * fileScale;
+ canvas.width = renderingWidth;
+ canvas.height = renderingHeight;
+ width = renderingWidth;
+ height = renderingHeight;
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(snapshot, 0, 0, renderingWidth, renderingHeight);
+
+ // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies
+ // of the bitmap will exist in memory. Force the removal of the snapshot
+ // because it is no longer needed.
+ snapshot.close();
+
+ return canvas.toDataURL("image/png", "");
+ } catch (e) {
+ return null;
+ }
+ };
+
+ const ratio = args.snapshotScale;
+ let data = await drawToCanvas(ratio);
+ if (!data && ratio > 1.0) {
+ // If the user provided DPR or the window.devicePixelRatio was higher than 1,
+ // try again with a reduced ratio.
+ messages.push({
+ level: "warn",
+ text: L10N.getStr("screenshotDPRDecreasedWarning"),
+ });
+ data = await drawToCanvas(1.0);
+ }
+ if (!data) {
+ messages.push({
+ level: "error",
+ text: L10N.getStr("screenshotRenderingError"),
+ });
+ }
+
+ if (data && args.disableFlash !== true) {
+ simulateCameraFlash(browsingContext);
+ }
+
+ return {
+ data,
+ height,
+ width,
+ filename,
+ messages,
+ };
+}
+
+exports.captureScreenshot = captureScreenshot;
+
+/**
+ * We may have a filename specified in args, or we might have to generate
+ * one.
+ */
+function getFilename(defaultName) {
+ // Create a name for the file if not present
+ if (defaultName) {
+ return defaultName;
+ }
+
+ const date = new Date();
+ const monthString = (date.getMonth() + 1).toString().padStart(2, "0");
+ const dayString = date.getDate().toString().padStart(2, "0");
+ const dateString = `${date.getFullYear()}-${monthString}-${dayString}`;
+
+ const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0];
+
+ return (
+ L10N.getFormatStr("screenshotGeneratedFilename", dateString, timeString) +
+ ".png"
+ );
+}
diff --git a/devtools/server/actors/utils/css-grid-utils.js b/devtools/server/actors/utils/css-grid-utils.js
new file mode 100644
index 0000000000..9631dcd800
--- /dev/null
+++ b/devtools/server/actors/utils/css-grid-utils.js
@@ -0,0 +1,60 @@
+/* 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";
+
+/**
+ * Returns the grid fragment array with all the grid fragment data stringifiable.
+ *
+ * @param {Object} fragments
+ * Grid fragment object.
+ * @return {Array} representation with the grid fragment data stringifiable.
+ */
+function getStringifiableFragments(fragments = []) {
+ if (fragments[0] && Cu.isDeadWrapper(fragments[0])) {
+ return {};
+ }
+
+ return fragments.map(getStringifiableFragment);
+}
+
+function getStringifiableFragment(fragment) {
+ return {
+ areas: getStringifiableAreas(fragment.areas),
+ cols: getStringifiableDimension(fragment.cols),
+ rows: getStringifiableDimension(fragment.rows),
+ };
+}
+
+function getStringifiableAreas(areas) {
+ return [...areas].map(getStringifiableArea);
+}
+
+function getStringifiableDimension(dimension) {
+ return {
+ lines: [...dimension.lines].map(getStringifiableLine),
+ tracks: [...dimension.tracks].map(getStringifiableTrack),
+ };
+}
+
+function getStringifiableArea({
+ columnEnd,
+ columnStart,
+ name,
+ rowEnd,
+ rowStart,
+ type,
+}) {
+ return { columnEnd, columnStart, name, rowEnd, rowStart, type };
+}
+
+function getStringifiableLine({ breadth, names, number, start, type }) {
+ return { breadth, names, number, start, type };
+}
+
+function getStringifiableTrack({ breadth, start, state, type }) {
+ return { breadth, start, state, type };
+}
+
+exports.getStringifiableFragments = getStringifiableFragments;
diff --git a/devtools/server/actors/utils/custom-formatters.js b/devtools/server/actors/utils/custom-formatters.js
new file mode 100644
index 0000000000..e4ae20dad7
--- /dev/null
+++ b/devtools/server/actors/utils/custom-formatters.js
@@ -0,0 +1,499 @@
+/* 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,
+ "createValueGripForTarget",
+ "resource://devtools/server/actors/object/utils.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "ObjectUtils",
+ "resource://devtools/server/actors/object/utils.js"
+);
+
+const _invalidCustomFormatterHooks = new WeakSet();
+function addInvalidCustomFormatterHooks(hook) {
+ if (!hook) {
+ return;
+ }
+
+ try {
+ _invalidCustomFormatterHooks.add(hook);
+ } catch (e) {
+ console.error("Couldn't add hook to the WeakSet", hook);
+ }
+}
+
+// Custom exception used between customFormatterHeader and processFormatterForHeader
+class FormatterError extends Error {
+ constructor(message, script) {
+ super(message);
+ this.script = script;
+ }
+}
+
+/**
+ * Handle a protocol request to get the custom formatter header for an object.
+ * This is typically returned into ObjectActor's form if custom formatters are enabled.
+ *
+ * @param {ObjectActor} objectActor
+ *
+ * @returns {Object} Data related to the custom formatter header:
+ * - {boolean} useCustomFormatter, indicating if a custom formatter is used.
+ * - {Array} header JsonML of the output header.
+ * - {boolean} hasBody True in case the custom formatter has a body.
+ * - {Object} formatter The devtoolsFormatters item that was being used to format
+ * the object.
+ */
+function customFormatterHeader(objectActor) {
+ const rawValue = objectActor.rawValue();
+ const globalWrapper = Cu.getGlobalForObject(rawValue);
+ const global = globalWrapper?.wrappedJSObject;
+
+ // We expect a `devtoolsFormatters` global attribute and it to be an array
+ if (!global || !Array.isArray(global.devtoolsFormatters)) {
+ return null;
+ }
+
+ const customFormatterTooDeep =
+ (objectActor.hooks.customFormatterObjectTagDepth || 0) > 20;
+ if (customFormatterTooDeep) {
+ logCustomFormatterError(
+ globalWrapper,
+ `Too deep hierarchy of inlined custom previews`
+ );
+ return null;
+ }
+
+ const targetActor = objectActor.thread._parent;
+
+ const {
+ customFormatterConfigDbgObj: configDbgObj,
+ customFormatterObjectTagDepth,
+ } = objectActor.hooks;
+
+ const valueDbgObj = objectActor.obj;
+
+ for (const [
+ customFormatterIndex,
+ formatter,
+ ] of global.devtoolsFormatters.entries()) {
+ // If the message for the erroneous formatter already got logged,
+ // skip logging it again.
+ if (_invalidCustomFormatterHooks.has(formatter)) {
+ continue;
+ }
+
+ // TODO: Any issues regarding the implementation will be covered in https://bugzil.la/1776611.
+ try {
+ const rv = processFormatterForHeader({
+ configDbgObj,
+ customFormatterObjectTagDepth,
+ formatter,
+ targetActor,
+ valueDbgObj,
+ });
+ // Return the first valid formatter value
+ if (rv) {
+ return rv;
+ }
+ } catch (e) {
+ logCustomFormatterError(
+ globalWrapper,
+ e instanceof FormatterError
+ ? `devtoolsFormatters[${customFormatterIndex}].${e.message}`
+ : `devtoolsFormatters[${customFormatterIndex}] couldn't be run: ${e.message}`,
+ // If the exception is FormatterError, this comes with a script attribute
+ e.script
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ }
+ }
+
+ return null;
+}
+exports.customFormatterHeader = customFormatterHeader;
+
+/**
+ * Handle one precise custom formatter.
+ * i.e. one element of the window.customFormatters Array.
+ *
+ * @param {Object} options
+ * @param {Debugger.Object} options.configDbgObj
+ * The Debugger.Object of the config object.
+ * @param {Number} options.customFormatterObjectTagDepth
+ * See buildJsonMlFromCustomFormatterHookResult JSDoc.
+ * @param {Object} options.formatter
+ * The raw formatter object (coming from "customFormatter" array).
+ * @param {BrowsingContextTargetActor} options.targetActor
+ * See buildJsonMlFromCustomFormatterHookResult JSDoc.
+ * @param {Debugger.Object} options.valueDbgObj
+ * The Debugger.Object of rawValue.
+ *
+ * @returns {Object} See customFormatterHeader jsdoc, it returns the same object.
+ */
+function processFormatterForHeader({
+ configDbgObj,
+ customFormatterObjectTagDepth,
+ formatter,
+ targetActor,
+ valueDbgObj,
+}) {
+ const headerType = typeof formatter?.header;
+ if (headerType !== "function") {
+ throw new FormatterError(`header should be a function, got ${headerType}`);
+ }
+
+ // Call the formatter's header attribute, which should be a function.
+ const formatterHeaderDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
+ valueDbgObj,
+ formatter.header
+ );
+ const header = formatterHeaderDbgValue.call(
+ formatterHeaderDbgValue.boundThis,
+ valueDbgObj,
+ configDbgObj
+ );
+
+ // If the header returns null, the custom formatter isn't used for that object
+ if (header?.return === null) {
+ return null;
+ }
+
+ // The header has to be an Array, all other cases are errors
+ if (header?.return?.class !== "Array") {
+ let errorMsg = "";
+ if (header == null) {
+ errorMsg = `header was not run because it has side effects`;
+ } else if ("return" in header) {
+ let type = typeof header.return;
+ if (type === "object") {
+ type = header.return?.class;
+ }
+ errorMsg = `header should return an array, got ${type}`;
+ } else if ("throw" in header) {
+ errorMsg = `header threw: ${header.throw.getProperty("message")?.return}`;
+ }
+
+ throw new FormatterError(errorMsg, formatterHeaderDbgValue?.script);
+ }
+
+ const rawHeader = header.return.unsafeDereference();
+ if (rawHeader.length === 0) {
+ throw new FormatterError(
+ `header returned an empty array`,
+ formatterHeaderDbgValue?.script
+ );
+ }
+
+ const sanitizedHeader = buildJsonMlFromCustomFormatterHookResult(
+ header.return,
+ customFormatterObjectTagDepth,
+ targetActor
+ );
+
+ let hasBody = false;
+ const hasBodyType = typeof formatter?.hasBody;
+ if (hasBodyType === "function") {
+ const formatterHasBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
+ valueDbgObj,
+ formatter.hasBody
+ );
+ hasBody = formatterHasBodyDbgValue.call(
+ formatterHasBodyDbgValue.boundThis,
+ valueDbgObj,
+ configDbgObj
+ );
+
+ if (hasBody == null) {
+ throw new FormatterError(
+ `hasBody was not run because it has side effects`,
+ formatterHasBodyDbgValue?.script
+ );
+ } else if ("throw" in hasBody) {
+ throw new FormatterError(
+ `hasBody threw: ${hasBody.throw.getProperty("message")?.return}`,
+ formatterHasBodyDbgValue?.script
+ );
+ }
+ } else if (hasBodyType !== "undefined") {
+ throw new FormatterError(
+ `hasBody should be a function, got ${hasBodyType}`
+ );
+ }
+
+ return {
+ useCustomFormatter: true,
+ header: sanitizedHeader,
+ hasBody: !!hasBody?.return,
+ formatter,
+ };
+}
+
+/**
+ * Handle a protocol request to get the custom formatter body for an object
+ *
+ * @param {ObjectActor} objectActor
+ * @param {Object} formatter: The global.devtoolsFormatters entry that was used in customFormatterHeader
+ * for this object.
+ *
+ * @returns {Object} Data related to the custom formatter body:
+ * - {*} customFormatterBody Data of the custom formatter body.
+ */
+async function customFormatterBody(objectActor, formatter) {
+ const rawValue = objectActor.rawValue();
+ const globalWrapper = Cu.getGlobalForObject(rawValue);
+ const global = globalWrapper?.wrappedJSObject;
+
+ const customFormatterIndex = global.devtoolsFormatters.indexOf(formatter);
+
+ const targetActor = objectActor.thread._parent;
+ try {
+ const { customFormatterConfigDbgObj, customFormatterObjectTagDepth } =
+ objectActor.hooks;
+
+ if (_invalidCustomFormatterHooks.has(formatter)) {
+ return {
+ customFormatterBody: null,
+ };
+ }
+
+ const bodyType = typeof formatter.body;
+ if (bodyType !== "function") {
+ logCustomFormatterError(
+ globalWrapper,
+ `devtoolsFormatters[${customFormatterIndex}].body should be a function, got ${bodyType}`
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ return {
+ customFormatterBody: null,
+ };
+ }
+
+ const formatterBodyDbgValue = ObjectUtils.makeDebuggeeValueIfNeeded(
+ objectActor.obj,
+ formatter.body
+ );
+ const body = formatterBodyDbgValue.call(
+ formatterBodyDbgValue.boundThis,
+ objectActor.obj,
+ customFormatterConfigDbgObj
+ );
+ if (body?.return?.class === "Array") {
+ const rawBody = body.return.unsafeDereference();
+ if (rawBody.length === 0) {
+ logCustomFormatterError(
+ globalWrapper,
+ `devtoolsFormatters[${customFormatterIndex}].body returned an empty array`,
+ formatterBodyDbgValue?.script
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ return {
+ customFormatterBody: null,
+ };
+ }
+
+ const customFormatterBodyJsonMl =
+ buildJsonMlFromCustomFormatterHookResult(
+ body.return,
+ customFormatterObjectTagDepth,
+ targetActor
+ );
+
+ return {
+ customFormatterBody: customFormatterBodyJsonMl,
+ };
+ }
+
+ let errorMsg = "";
+ if (body == null) {
+ errorMsg = `devtoolsFormatters[${customFormatterIndex}].body was not run because it has side effects`;
+ } else if ("return" in body) {
+ let type = body.return === null ? "null" : typeof body.return;
+ if (type === "object") {
+ type = body.return?.class;
+ }
+ errorMsg = `devtoolsFormatters[${customFormatterIndex}].body should return an array, got ${type}`;
+ } else if ("throw" in body) {
+ errorMsg = `devtoolsFormatters[${customFormatterIndex}].body threw: ${
+ body.throw.getProperty("message")?.return
+ }`;
+ }
+
+ logCustomFormatterError(
+ globalWrapper,
+ errorMsg,
+ formatterBodyDbgValue?.script
+ );
+ addInvalidCustomFormatterHooks(formatter);
+ } catch (e) {
+ logCustomFormatterError(
+ globalWrapper,
+ `Custom formatter with index ${customFormatterIndex} couldn't be run: ${e.message}`
+ );
+ }
+
+ return {};
+}
+exports.customFormatterBody = customFormatterBody;
+
+/**
+ * Log an error caused by a fault in a custom formatter to the web console.
+ *
+ * @param {Window} window The related global where we should log this message.
+ * This should be the xray wrapper in order to expose windowGlobalChild.
+ * The unwrapped, unpriviledged won't expose this attribute.
+ * @param {string} errorMsg Message to log to the console.
+ * @param {DebuggerObject} [script] The script causing the error.
+ */
+function logCustomFormatterError(window, errorMsg, script) {
+ const scriptErrorClass = Cc["@mozilla.org/scripterror;1"];
+ const scriptError = scriptErrorClass.createInstance(Ci.nsIScriptError);
+ const { url, source, startLine, startColumn } = script ?? {};
+
+ scriptError.initWithWindowID(
+ `Custom formatter failed: ${errorMsg}`,
+ url,
+ source,
+ startLine,
+ startColumn,
+ Ci.nsIScriptError.errorFlag,
+ "devtoolsFormatter",
+ window.windowGlobalChild.innerWindowId
+ );
+ Services.console.logMessage(scriptError);
+}
+
+/**
+ * Return a ready to use JsonMl object, safe to be sent to the client.
+ * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
+ * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
+ * if the referenced object gets custom formatted as well.
+ *
+ * @param {DebuggerObject} jsonMlDbgObj: The debugger object representing a jsonMl object returned
+ * by a custom formatter hook.
+ * @param {Number} customFormatterObjectTagDepth: See `processObjectTag`.
+ * @param {BrowsingContextTargetActor} targetActor: The actor that will be managing any
+ * created ObjectActor.
+ * @returns {Array|null} Returns null if the passed object is a not DebuggerObject representing an Array
+ */
+function buildJsonMlFromCustomFormatterHookResult(
+ jsonMlDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+) {
+ const tagName = jsonMlDbgObj.getProperty(0)?.return;
+ if (typeof tagName !== "string") {
+ const tagNameType =
+ tagName?.class || (tagName === null ? "null" : typeof tagName);
+ throw new Error(`tagName should be a string, got ${tagNameType}`);
+ }
+
+ // Fetch the other items of the jsonMl
+ const rest = [];
+ const dbgObjLength = jsonMlDbgObj.getProperty("length")?.return || 0;
+ for (let i = 1; i < dbgObjLength; i++) {
+ rest.push(jsonMlDbgObj.getProperty(i)?.return);
+ }
+
+ // The second item of the array can either be an object holding the attributes
+ // for the element or the first child element.
+ const attributesDbgObj =
+ rest[0] && rest[0].class === "Object" ? rest[0] : null;
+ const childrenDbgObj = attributesDbgObj ? rest.slice(1) : rest;
+
+ // If the tagName is "object", we need to replace the entry with the grip representing
+ // this object (that may or may not be custom formatted).
+ if (tagName == "object") {
+ if (!attributesDbgObj) {
+ throw new Error(`"object" tag should have attributes`);
+ }
+
+ // TODO: We could emit a warning if `childrenDbgObj` isn't empty as we're going to
+ // ignore them here.
+ return processObjectTag(
+ attributesDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+ );
+ }
+
+ const jsonMl = [tagName, {}];
+ if (attributesDbgObj) {
+ // For non "object" tags, we only care about the style property
+ jsonMl[1].style = attributesDbgObj.getProperty("style")?.return;
+ }
+
+ // Handle children, which could be simple primitives or JsonML objects
+ for (const childDbgObj of childrenDbgObj) {
+ const childDbgObjType = typeof childDbgObj;
+ if (childDbgObj?.class === "Array") {
+ // `childDbgObj` probably holds a JsonMl item, sanitize it.
+ jsonMl.push(
+ buildJsonMlFromCustomFormatterHookResult(
+ childDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+ )
+ );
+ } else if (childDbgObjType == "object" && childDbgObj !== null) {
+ // If we don't have an array, match Chrome implementation.
+ jsonMl.push("[object Object]");
+ } else {
+ // Here `childDbgObj` is a primitive. Create a grip so we can handle all the types
+ // we can stringify easily (e.g. `undefined`, `bigint`, …).
+ const grip = createValueGripForTarget(targetActor, childDbgObj);
+ if (grip !== null) {
+ jsonMl.push(grip);
+ }
+ }
+ }
+ return jsonMl;
+}
+
+/**
+ * Return a ready to use JsonMl object, safe to be sent to the client.
+ * This will replace JsonMl items with object reference, e.g `[ "object", { config: ..., object: ... } ]`
+ * with objectActor grip or "regular" JsonMl items (e.g. `["span", {style: "color: red"}, "this is", "an object"]`)
+ * if the referenced object gets custom formatted as well.
+ *
+ * @param {DebuggerObject} attributesDbgObj: The debugger object representing the "attributes"
+ * of a jsonMl item (e.g. the second item in the array).
+ * @param {Number} customFormatterObjectTagDepth: As "object" tag can reference custom
+ * formatted data, we track the number of time we go through this function
+ * from the "root" object so we don't have an infinite loop.
+ * @param {BrowsingContextTargetActor} targetActor: The actor that will be managin any
+ * created ObjectActor.
+ * @returns {Object} Returns a grip representing the underlying object
+ */
+function processObjectTag(
+ attributesDbgObj,
+ customFormatterObjectTagDepth,
+ targetActor
+) {
+ const objectDbgObj = attributesDbgObj.getProperty("object")?.return;
+ if (typeof objectDbgObj == "undefined") {
+ throw new Error(
+ `attribute of "object" tag should have an "object" property`
+ );
+ }
+
+ // We need to replace the "object" tag with the actual `attribute.object` object,
+ // which might be also custom formatted.
+ // We create the grip so the custom formatter hooks can be called on this object, or
+ // we'd get an object grip that we can consume to display an ObjectInspector on the client.
+ const configRv = attributesDbgObj.getProperty("config");
+ const grip = createValueGripForTarget(targetActor, objectDbgObj, 0, {
+ // Store the config so we can pass it when calling custom formatter hooks for this object.
+ customFormatterConfigDbgObj: configRv?.return,
+ customFormatterObjectTagDepth: (customFormatterObjectTagDepth || 0) + 1,
+ });
+
+ return grip;
+}
diff --git a/devtools/server/actors/utils/dbg-source.js b/devtools/server/actors/utils/dbg-source.js
new file mode 100644
index 0000000000..9c4111dfaa
--- /dev/null
+++ b/devtools/server/actors/utils/dbg-source.js
@@ -0,0 +1,97 @@
+/* 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";
+
+/**
+ * Get the source text offset equivalent to a given line/column pair.
+ *
+ * @param {Debugger.Source} source
+ * @param {number} line The 1-based line number.
+ * @param {number} column The 0-based column number.
+ * @returns {number} The codepoint offset into the source's text.
+ */
+function findSourceOffset(source, line, column) {
+ const offsets = getSourceLineOffsets(source);
+ const offset = offsets[line - 1];
+
+ if (offset) {
+ // Make sure that columns that technically don't exist in the line text
+ // don't cause the offset to wrap to the next line.
+ return Math.min(offset.start + column, offset.textEnd);
+ }
+
+ return line < 0 ? 0 : offsets[offsets.length - 1].end;
+}
+exports.findSourceOffset = findSourceOffset;
+
+const NEWLINE = /(\r?\n|\r|\u2028|\u2029)/g;
+const SOURCE_OFFSETS = new WeakMap();
+/**
+ * Generate and cache line information for a given source to track what
+ * text offsets mark the start and end of lines. Each entry in the array
+ * represents a line in the source text.
+ *
+ * @param {Debugger.Source} source
+ * @returns {Array<{ start, textEnd, end }>}
+ * - start - The codepoint offset of the start of the line.
+ * - textEnd - The codepoint offset just after the last non-newline character.
+ * - end - The codepoint offset of the end of the line. This will be
+ * be the same as the 'start' value of the next offset object,
+ * and this includes the newlines for the line itself, where
+ * 'textEnd' excludes newline characters.
+ */
+function getSourceLineOffsets(source) {
+ const cached = SOURCE_OFFSETS.get(source);
+ if (cached) {
+ return cached;
+ }
+
+ const { text } = source;
+
+ const lines = text.split(NEWLINE);
+
+ const offsets = [];
+ let offset = 0;
+ for (let i = 0; i < lines.length; i += 2) {
+ const line = lines[i];
+ const start = offset;
+
+ // Calculate the end codepoint offset.
+ let end = offset;
+ // eslint-disable-next-line no-unused-vars
+ for (const c of line) {
+ end++;
+ }
+ const textEnd = end;
+
+ if (i + 1 < lines.length) {
+ end += lines[i + 1].length;
+ }
+
+ offsets.push(Object.freeze({ start, textEnd, end }));
+ offset = end;
+ }
+ Object.freeze(offsets);
+
+ SOURCE_OFFSETS.set(source, offsets);
+ return offsets;
+}
+
+/**
+ * Given a target actor and a source platform internal ID,
+ * return the related SourceActor ID.
+
+ * @param TargetActor targetActor
+ * The Target Actor from which this source originates.
+ * @param String id
+ * Platform Source ID
+ * @return String
+ * The SourceActor ID
+ */
+function getActorIdForInternalSourceId(targetActor, id) {
+ const actor = targetActor.sourcesManager.getSourceActorByInternalSourceId(id);
+ return actor ? actor.actorID : null;
+}
+exports.getActorIdForInternalSourceId = getActorIdForInternalSourceId;
diff --git a/devtools/server/actors/utils/event-breakpoints.js b/devtools/server/actors/utils/event-breakpoints.js
new file mode 100644
index 0000000000..a7752b8201
--- /dev/null
+++ b/devtools/server/actors/utils/event-breakpoints.js
@@ -0,0 +1,508 @@
+/* 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";
+
+/**
+ *
+ * @param {String} groupID
+ * @param {String} eventType
+ * @param {Function} condition: Optional function that takes a Window as parameter. When
+ * passed, the event will only be included if the result of the function
+ * call is `true` (See `getAvailableEventBreakpoints`).
+ * @returns {Object}
+ */
+function generalEvent(groupID, eventType, condition) {
+ return {
+ id: `event.${groupID}.${eventType}`,
+ type: "event",
+ name: eventType,
+ message: `DOM '${eventType}' event`,
+ eventType,
+ filter: "general",
+ condition,
+ };
+}
+function nodeEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ filter: "node",
+ };
+}
+function mediaNodeEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ filter: "media",
+ };
+}
+function globalEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ message: `Global '${eventType}' event`,
+ filter: "global",
+ };
+}
+function xhrEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ message: `XHR '${eventType}' event`,
+ filter: "xhr",
+ };
+}
+
+function webSocketEvent(groupID, eventType) {
+ return {
+ ...generalEvent(groupID, eventType),
+ message: `WebSocket '${eventType}' event`,
+ filter: "websocket",
+ };
+}
+
+function workerEvent(eventType) {
+ return {
+ ...generalEvent("worker", eventType),
+ message: `Worker '${eventType}' event`,
+ filter: "worker",
+ };
+}
+
+function timerEvent(type, operation, name, notificationType) {
+ return {
+ id: `timer.${type}.${operation}`,
+ type: "simple",
+ name,
+ message: name,
+ notificationType,
+ };
+}
+
+function animationEvent(operation, name, notificationType) {
+ return {
+ id: `animationframe.${operation}`,
+ type: "simple",
+ name,
+ message: name,
+ notificationType,
+ };
+}
+
+const SCRIPT_FIRST_STATEMENT_BREAKPOINT = {
+ id: "script.source.firstStatement",
+ type: "script",
+ name: "Script First Statement",
+ message: "Script First Statement",
+};
+
+const AVAILABLE_BREAKPOINTS = [
+ {
+ name: "Animation",
+ items: [
+ animationEvent(
+ "request",
+ "Request Animation Frame",
+ "requestAnimationFrame"
+ ),
+ animationEvent(
+ "cancel",
+ "Cancel Animation Frame",
+ "cancelAnimationFrame"
+ ),
+ animationEvent(
+ "fire",
+ "Animation Frame fired",
+ "requestAnimationFrameCallback"
+ ),
+ ],
+ },
+ {
+ name: "Clipboard",
+ items: [
+ generalEvent("clipboard", "copy"),
+ generalEvent("clipboard", "cut"),
+ generalEvent("clipboard", "paste"),
+ generalEvent("clipboard", "beforecopy"),
+ generalEvent("clipboard", "beforecut"),
+ generalEvent("clipboard", "beforepaste"),
+ ],
+ },
+ {
+ name: "Control",
+ items: [
+ // The condition should be removed when "dom.element.popover.enabled" is removed
+ generalEvent("control", "beforetoggle", () =>
+ Services.prefs.getBoolPref("dom.element.popover.enabled")
+ ),
+ generalEvent("control", "blur"),
+ generalEvent("control", "change"),
+ generalEvent("control", "focus"),
+ generalEvent("control", "focusin"),
+ generalEvent("control", "focusout"),
+ // The condition should be removed when "dom.element.invokers.enabled" is removed
+ generalEvent("control", "invoke", win => "InvokeEvent" in win),
+ generalEvent("control", "reset"),
+ generalEvent("control", "resize"),
+ generalEvent("control", "scroll"),
+ generalEvent("control", "scrollend"),
+ generalEvent("control", "select"),
+ generalEvent("control", "toggle"),
+ generalEvent("control", "submit"),
+ generalEvent("control", "zoom"),
+ ],
+ },
+ {
+ name: "DOM Mutation",
+ items: [
+ // Deprecated DOM events.
+ nodeEvent("dom-mutation", "DOMActivate"),
+ nodeEvent("dom-mutation", "DOMFocusIn"),
+ nodeEvent("dom-mutation", "DOMFocusOut"),
+
+ // Standard DOM mutation events.
+ nodeEvent("dom-mutation", "DOMAttrModified"),
+ nodeEvent("dom-mutation", "DOMCharacterDataModified"),
+ nodeEvent("dom-mutation", "DOMNodeInserted"),
+ nodeEvent("dom-mutation", "DOMNodeInsertedIntoDocument"),
+ nodeEvent("dom-mutation", "DOMNodeRemoved"),
+ nodeEvent("dom-mutation", "DOMNodeRemovedIntoDocument"),
+ nodeEvent("dom-mutation", "DOMSubtreeModified"),
+
+ // DOM load events.
+ nodeEvent("dom-mutation", "DOMContentLoaded"),
+ ],
+ },
+ {
+ name: "Device",
+ items: [
+ globalEvent("device", "deviceorientation"),
+ globalEvent("device", "devicemotion"),
+ ],
+ },
+ {
+ name: "Drag and Drop",
+ items: [
+ generalEvent("drag-and-drop", "drag"),
+ generalEvent("drag-and-drop", "dragstart"),
+ generalEvent("drag-and-drop", "dragend"),
+ generalEvent("drag-and-drop", "dragenter"),
+ generalEvent("drag-and-drop", "dragover"),
+ generalEvent("drag-and-drop", "dragleave"),
+ generalEvent("drag-and-drop", "drop"),
+ ],
+ },
+ {
+ name: "Keyboard",
+ items: [
+ generalEvent("keyboard", "beforeinput"),
+ generalEvent("keyboard", "input"),
+ generalEvent("keyboard", "keydown"),
+ generalEvent("keyboard", "keyup"),
+ generalEvent("keyboard", "keypress"),
+ generalEvent("keyboard", "compositionstart"),
+ generalEvent("keyboard", "compositionupdate"),
+ generalEvent("keyboard", "compositionend"),
+ ].filter(Boolean),
+ },
+ {
+ name: "Load",
+ items: [
+ globalEvent("load", "load"),
+ globalEvent("load", "beforeunload"),
+ globalEvent("load", "unload"),
+ globalEvent("load", "abort"),
+ globalEvent("load", "error"),
+ globalEvent("load", "hashchange"),
+ globalEvent("load", "popstate"),
+ ],
+ },
+ {
+ name: "Media",
+ items: [
+ mediaNodeEvent("media", "play"),
+ mediaNodeEvent("media", "pause"),
+ mediaNodeEvent("media", "playing"),
+ mediaNodeEvent("media", "canplay"),
+ mediaNodeEvent("media", "canplaythrough"),
+ mediaNodeEvent("media", "seeking"),
+ mediaNodeEvent("media", "seeked"),
+ mediaNodeEvent("media", "timeupdate"),
+ mediaNodeEvent("media", "ended"),
+ mediaNodeEvent("media", "ratechange"),
+ mediaNodeEvent("media", "durationchange"),
+ mediaNodeEvent("media", "volumechange"),
+ mediaNodeEvent("media", "loadstart"),
+ mediaNodeEvent("media", "progress"),
+ mediaNodeEvent("media", "suspend"),
+ mediaNodeEvent("media", "abort"),
+ mediaNodeEvent("media", "error"),
+ mediaNodeEvent("media", "emptied"),
+ mediaNodeEvent("media", "stalled"),
+ mediaNodeEvent("media", "loadedmetadata"),
+ mediaNodeEvent("media", "loadeddata"),
+ mediaNodeEvent("media", "waiting"),
+ ],
+ },
+ {
+ name: "Mouse",
+ items: [
+ generalEvent("mouse", "auxclick"),
+ generalEvent("mouse", "click"),
+ generalEvent("mouse", "dblclick"),
+ generalEvent("mouse", "mousedown"),
+ generalEvent("mouse", "mouseup"),
+ generalEvent("mouse", "mouseover"),
+ generalEvent("mouse", "mousemove"),
+ generalEvent("mouse", "mouseout"),
+ generalEvent("mouse", "mouseenter"),
+ generalEvent("mouse", "mouseleave"),
+ generalEvent("mouse", "mousewheel"),
+ generalEvent("mouse", "wheel"),
+ generalEvent("mouse", "contextmenu"),
+ ],
+ },
+ {
+ name: "Pointer",
+ items: [
+ generalEvent("pointer", "pointerover"),
+ generalEvent("pointer", "pointerout"),
+ generalEvent("pointer", "pointerenter"),
+ generalEvent("pointer", "pointerleave"),
+ generalEvent("pointer", "pointerdown"),
+ generalEvent("pointer", "pointerup"),
+ generalEvent("pointer", "pointermove"),
+ generalEvent("pointer", "pointercancel"),
+ generalEvent("pointer", "gotpointercapture"),
+ generalEvent("pointer", "lostpointercapture"),
+ ],
+ },
+ {
+ name: "Script",
+ items: [SCRIPT_FIRST_STATEMENT_BREAKPOINT],
+ },
+ {
+ name: "Timer",
+ items: [
+ timerEvent("timeout", "set", "setTimeout", "setTimeout"),
+ timerEvent("timeout", "clear", "clearTimeout", "clearTimeout"),
+ timerEvent("timeout", "fire", "setTimeout fired", "setTimeoutCallback"),
+ timerEvent("interval", "set", "setInterval", "setInterval"),
+ timerEvent("interval", "clear", "clearInterval", "clearInterval"),
+ timerEvent(
+ "interval",
+ "fire",
+ "setInterval fired",
+ "setIntervalCallback"
+ ),
+ ],
+ },
+ {
+ name: "Touch",
+ items: [
+ generalEvent("touch", "touchstart"),
+ generalEvent("touch", "touchmove"),
+ generalEvent("touch", "touchend"),
+ generalEvent("touch", "touchcancel"),
+ ],
+ },
+ {
+ name: "WebSocket",
+ items: [
+ webSocketEvent("websocket", "open"),
+ webSocketEvent("websocket", "message"),
+ webSocketEvent("websocket", "error"),
+ webSocketEvent("websocket", "close"),
+ ],
+ },
+ {
+ name: "Worker",
+ items: [
+ workerEvent("message"),
+ workerEvent("messageerror"),
+
+ // Service Worker events.
+ globalEvent("serviceworker", "fetch"),
+ ],
+ },
+ {
+ name: "XHR",
+ items: [
+ xhrEvent("xhr", "readystatechange"),
+ xhrEvent("xhr", "load"),
+ xhrEvent("xhr", "loadstart"),
+ xhrEvent("xhr", "loadend"),
+ xhrEvent("xhr", "abort"),
+ xhrEvent("xhr", "error"),
+ xhrEvent("xhr", "progress"),
+ xhrEvent("xhr", "timeout"),
+ ],
+ },
+];
+
+const FLAT_EVENTS = [];
+for (const category of AVAILABLE_BREAKPOINTS) {
+ for (const event of category.items) {
+ FLAT_EVENTS.push(event);
+ }
+}
+const EVENTS_BY_ID = {};
+for (const event of FLAT_EVENTS) {
+ if (EVENTS_BY_ID[event.id]) {
+ throw new Error("Duplicate event ID detected: " + event.id);
+ }
+ EVENTS_BY_ID[event.id] = event;
+}
+
+const SIMPLE_EVENTS = {};
+const DOM_EVENTS = {};
+for (const eventBP of FLAT_EVENTS) {
+ if (eventBP.type === "simple") {
+ const { notificationType } = eventBP;
+ if (SIMPLE_EVENTS[notificationType]) {
+ throw new Error("Duplicate simple event");
+ }
+ SIMPLE_EVENTS[notificationType] = eventBP.id;
+ } else if (eventBP.type === "event") {
+ const { eventType, filter } = eventBP;
+
+ let targetTypes;
+ if (filter === "global") {
+ targetTypes = ["global"];
+ } else if (filter === "xhr") {
+ targetTypes = ["xhr"];
+ } else if (filter === "websocket") {
+ targetTypes = ["websocket"];
+ } else if (filter === "worker") {
+ targetTypes = ["worker"];
+ } else if (filter === "general") {
+ targetTypes = ["global", "node"];
+ } else if (filter === "node" || filter === "media") {
+ targetTypes = ["node"];
+ } else {
+ throw new Error("Unexpected filter type");
+ }
+
+ for (const targetType of targetTypes) {
+ let byEventType = DOM_EVENTS[targetType];
+ if (!byEventType) {
+ byEventType = {};
+ DOM_EVENTS[targetType] = byEventType;
+ }
+
+ if (byEventType[eventType]) {
+ throw new Error("Duplicate dom event: " + eventType);
+ }
+ byEventType[eventType] = eventBP.id;
+ }
+ } else if (eventBP.type === "script") {
+ // Nothing to do.
+ } else {
+ throw new Error("Unknown type: " + eventBP.type);
+ }
+}
+
+exports.eventBreakpointForNotification = eventBreakpointForNotification;
+function eventBreakpointForNotification(dbg, notification) {
+ const notificationType = notification.type;
+
+ if (notification.type === "domEvent") {
+ const domEventNotification = DOM_EVENTS[notification.targetType];
+ if (!domEventNotification) {
+ return null;
+ }
+
+ // The 'event' value is a cross-compartment wrapper for the DOM Event object.
+ // While we could use that directly in the main thread as an Xray wrapper,
+ // when debugging workers we can't, because it is an opaque wrapper.
+ // To make things work, we have to always interact with the Event object via
+ // the Debugger.Object interface.
+ const evt = dbg
+ .makeGlobalObjectReference(notification.global)
+ .makeDebuggeeValue(notification.event);
+
+ const eventType = evt.getProperty("type").return;
+ const id = domEventNotification[eventType];
+ if (!id) {
+ return null;
+ }
+ const eventBreakpoint = EVENTS_BY_ID[id];
+
+ if (eventBreakpoint.filter === "media") {
+ const currentTarget = evt.getProperty("currentTarget").return;
+ if (!currentTarget) {
+ return null;
+ }
+
+ const nodeType = currentTarget.getProperty("nodeType").return;
+ const namespaceURI = currentTarget.getProperty("namespaceURI").return;
+ if (
+ nodeType !== 1 /* ELEMENT_NODE */ ||
+ namespaceURI !== "http://www.w3.org/1999/xhtml"
+ ) {
+ return null;
+ }
+
+ const nodeName = currentTarget
+ .getProperty("nodeName")
+ .return.toLowerCase();
+ if (nodeName !== "audio" && nodeName !== "video") {
+ return null;
+ }
+ }
+
+ return id;
+ }
+
+ return SIMPLE_EVENTS[notificationType] || null;
+}
+
+exports.makeEventBreakpointMessage = makeEventBreakpointMessage;
+function makeEventBreakpointMessage(id) {
+ return EVENTS_BY_ID[id].message;
+}
+
+exports.firstStatementBreakpointId = firstStatementBreakpointId;
+function firstStatementBreakpointId() {
+ return SCRIPT_FIRST_STATEMENT_BREAKPOINT.id;
+}
+
+exports.eventsRequireNotifications = eventsRequireNotifications;
+function eventsRequireNotifications(ids) {
+ for (const id of ids) {
+ const eventBreakpoint = EVENTS_BY_ID[id];
+
+ // Script events are implemented directly in the server and do not require
+ // notifications from Gecko, so there is no need to watch for them.
+ if (eventBreakpoint && eventBreakpoint.type !== "script") {
+ return true;
+ }
+ }
+ return false;
+}
+
+exports.getAvailableEventBreakpoints = getAvailableEventBreakpoints;
+/**
+ * Get all available event breakpoints
+ *
+ * @param {Window} window
+ * @returns {Array<Object>} An array containing object with 2 properties, an id and a name,
+ * representing the event.
+ */
+function getAvailableEventBreakpoints(window) {
+ const available = [];
+ for (const { name, items } of AVAILABLE_BREAKPOINTS) {
+ available.push({
+ name,
+ events: items
+ .filter(item => !item.condition || item.condition(window))
+ .map(item => ({
+ id: item.id,
+ name: item.name,
+ })),
+ });
+ }
+ return available;
+}
+exports.validateEventBreakpoint = validateEventBreakpoint;
+function validateEventBreakpoint(id) {
+ return !!EVENTS_BY_ID[id];
+}
diff --git a/devtools/server/actors/utils/event-loop.js b/devtools/server/actors/utils/event-loop.js
new file mode 100644
index 0000000000..519d97ba7e
--- /dev/null
+++ b/devtools/server/actors/utils/event-loop.js
@@ -0,0 +1,221 @@
+/* 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 xpcInspector = require("xpcInspector");
+
+/**
+ * An object that represents a nested event loop. It is used as the nest
+ * requestor with nsIJSInspector instances.
+ *
+ * @param ThreadActor thread
+ * The thread actor that is creating this nested event loop.
+ */
+class EventLoop {
+ constructor({ thread }) {
+ this._thread = thread;
+
+ // A flag which is true in between the two calls to enter() and exit().
+ this._entered = false;
+ // Another flag which is true only after having called exit().
+ // Note that this EventLoop may still be paused and its enter() method
+ // still be on hold, if another EventLoop paused about this one.
+ this._resolved = false;
+ }
+
+ /**
+ * This is meant for other thread actors, and is used by other thread actor's
+ * EventLoop's isTheLastPausedThreadActor()
+ */
+ get thread() {
+ return this._thread;
+ }
+ /**
+ * Similarly, it will be used by another thread actor's EventLoop's enter() method
+ */
+ get resolved() {
+ return this._resolved;
+ }
+
+ /**
+ * Tells if the last thread actor to have paused (i.e. last EventLoop on the stack)
+ * is the current one.
+ *
+ * We avoid trying to exit this event loop,
+ * if another thread actor pile up a more recent one.
+ * All the event loops will be effectively exited when
+ * the thread actor which piled up the most recent nested event loop resumes.
+ *
+ * For convenience for the callsite, this will return true if nothing paused.
+ */
+ isTheLastPausedThreadActor() {
+ if (xpcInspector.eventLoopNestLevel > 0) {
+ return xpcInspector.lastNestRequestor.thread === this._thread;
+ }
+ return true;
+ }
+
+ /**
+ * Enter a new nested event loop.
+ */
+ enter() {
+ if (this._entered) {
+ throw new Error(
+ "Can't enter an event loop that has already been entered!"
+ );
+ }
+
+ const preEnterData = this.preEnter();
+
+ this._entered = true;
+ // Note: next line will synchronously block the execution until exit() is being called.
+ //
+ // This enterNestedEventLoop is a bit magical and will break run-to-completion rule of JS.
+ // JS will become multi-threaded. Some other task may start running on change state
+ // while we are blocked on this enterNestedEventLoop function call.
+ // You may find valuable information about Tasks and Event Loops on:
+ // https://docs.google.com/document/d/1jTMd-H_BwH9_QNUDxPse80vq884_hMvd234lvE5gqY8/edit?usp=sharing
+ //
+ // Note #2: this will update xpcInspector.lastNestRequestor to this
+ xpcInspector.enterNestedEventLoop(this);
+
+ // If this code runs, it means that we just exited this event loop and lastNestRequestor is no longer equal to this.
+ //
+ // We will now "recursively" exit all the resolved EventLoops which are blocked on `enterNestedEventLoop`:
+ // - if the new lastNestRequestor is resolved, request to exit it as well
+ // - this lastNestRequestor is another EventLoop instance
+ // - exiting this EventLoop unblocks its "enter" method and moves lastNestRequestor to the next requestor (if any)
+ // - we go back to the first step, and attempt to exit the new lastNestRequestor if it is resolved, etc...
+ if (xpcInspector.eventLoopNestLevel > 0) {
+ const { resolved } = xpcInspector.lastNestRequestor;
+ if (resolved) {
+ xpcInspector.exitNestedEventLoop();
+ }
+ }
+
+ this.postExit(preEnterData);
+ }
+
+ /**
+ * Exit this nested event loop.
+ *
+ * @returns boolean
+ * True if we exited this nested event loop because it was on top of
+ * the stack, false if there is another nested event loop above this
+ * one that hasn't exited yet.
+ */
+ exit() {
+ if (!this._entered) {
+ throw new Error("Can't exit an event loop before it has been entered!");
+ }
+ this._entered = false;
+ this._resolved = true;
+
+ // If another ThreadActor paused and spawn a new nested event loop after this one,
+ // let it resume the thread and ignore this call.
+ // The code calling exitNestedEventLoop from EventLoop.enter will resume execution,
+ // by seeing that resolved attribute that we just toggled is true.
+ //
+ // Note that ThreadActor.resume method avoids calling exit thanks to `isTheLastPausedThreadActor`
+ // So for all use requests to resume, the ThreadActor won't call exit until it is the last
+ // thread actor to have entered a nested EventLoop.
+ if (this === xpcInspector.lastNestRequestor) {
+ xpcInspector.exitNestedEventLoop();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Retrieve the list of all DOM Windows debugged by the current thread actor.
+ */
+ getAllWindowDebuggees() {
+ return this._thread.dbg
+ .getDebuggees()
+ .filter(debuggee => {
+ // Select only debuggee that relates to windows
+ // e.g. ignore sandboxes, jsm and such
+ return debuggee.class == "Window";
+ })
+ .map(debuggee => {
+ // Retrieve the JS reference for these windows
+ return debuggee.unsafeDereference();
+ })
+
+ .filter(window => {
+ // Ignore document which have already been nuked,
+ // so navigated to another location and removed from memory completely.
+ if (Cu.isDeadWrapper(window)) {
+ return false;
+ }
+ // Also ignore document which are closed, as trying to access window.parent or top would throw NS_ERROR_NOT_INITIALIZED
+ if (window.closed) {
+ return false;
+ }
+ // Ignore remote iframes, which will be debugged by another thread actor,
+ // running in the remote process
+ if (Cu.isRemoteProxy(window)) {
+ return false;
+ }
+ // Accept "top remote iframe document":
+ // document of iframe whose immediate parent is in another process.
+ if (Cu.isRemoteProxy(window.parent) && !Cu.isRemoteProxy(window)) {
+ return true;
+ }
+
+ // If EFT is enabled, accept any same process document (top-level or iframe).
+ if (this.thread.getParent().ignoreSubFrames) {
+ return true;
+ }
+
+ try {
+ // Ignore iframes running in the same process as their parent document,
+ // as they will be paused automatically when pausing their owner top level document
+ return window.top === window;
+ } catch (e) {
+ // Warn if this is throwing for an unknown reason, but suppress the
+ // exception regardless so that we can enter the nested event loop.
+ if (!/not initialized/.test(e)) {
+ console.warn(`Exception in getAllWindowDebuggees: ${e}`);
+ }
+ return false;
+ }
+ });
+ }
+
+ /**
+ * Prepare to enter a nested event loop by disabling debuggee events.
+ */
+ preEnter() {
+ const docShells = [];
+ // Disable events in all open windows.
+ for (const window of this.getAllWindowDebuggees()) {
+ const { windowUtils } = window;
+ windowUtils.suppressEventHandling(true);
+ windowUtils.suspendTimeouts();
+ docShells.push(window.docShell);
+ }
+ return docShells;
+ }
+
+ /**
+ * Prepare to exit a nested event loop by enabling debuggee events.
+ */
+ postExit(pausedDocShells) {
+ // Enable events in all window paused in preEnter
+ for (const docShell of pausedDocShells) {
+ // Do not try to resume documents which are in destruction
+ // as resume methods would throw
+ if (docShell.isBeingDestroyed()) {
+ continue;
+ }
+ const { windowUtils } = docShell.domWindow;
+ windowUtils.resumeTimeouts();
+ windowUtils.suppressEventHandling(false);
+ }
+ }
+}
+
+exports.EventLoop = EventLoop;
diff --git a/devtools/server/actors/utils/gecko-profile-collector.js b/devtools/server/actors/utils/gecko-profile-collector.js
new file mode 100644
index 0000000000..1cdb6d7e56
--- /dev/null
+++ b/devtools/server/actors/utils/gecko-profile-collector.js
@@ -0,0 +1,285 @@
+/* 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 fallback color for unexpected cases
+const DEFAULT_COLOR = "grey";
+
+// The default category for unexpected cases
+const DEFAULT_CATEGORIES = [
+ {
+ name: "Mixed",
+ color: DEFAULT_COLOR,
+ subcategories: ["Other"],
+ },
+];
+
+// Color for each type of category/frame's implementation
+const PREDEFINED_COLORS = {
+ interpreter: "yellow",
+ baseline: "orange",
+ ion: "blue",
+ wasm: "purple",
+};
+
+/**
+ * Utility class that collects the JS tracer data and converts it to a Gecko
+ * profile object.
+ */
+class GeckoProfileCollector {
+ #thread = null;
+ #stackMap = new Map();
+ #frameMap = new Map();
+ #categories = DEFAULT_CATEGORIES;
+ #currentStack = [];
+ #time = 0;
+
+ /**
+ * Initialize the profiler and be ready to receive samples.
+ */
+ start() {
+ this.#reset();
+ this.#thread = this.#getEmptyThread();
+ }
+
+ /**
+ * Stop the record and return the gecko profiler data.
+ *
+ * @return {Object}
+ * The Gecko profile object.
+ */
+ stop() {
+ // Create the profile to return.
+ const profile = this.#getEmptyProfile();
+ profile.meta.categories = this.#categories;
+ profile.threads.push(this.#thread);
+
+ // Cleanup.
+ this.#reset();
+
+ return profile;
+ }
+
+ /**
+ * Clear all the internal state of this class.
+ */
+ #reset() {
+ this.#thread = null;
+ this.#stackMap = new Map();
+ this.#frameMap = new Map();
+ this.#categories = DEFAULT_CATEGORIES;
+ this.#currentStack = [];
+ this.#time = 0;
+ }
+
+ /**
+ * Initialize an empty Gecko profile object.
+ *
+ * @return {Object}
+ * Gecko profile object.
+ */
+ #getEmptyProfile() {
+ const httpHandler = Cc[
+ "@mozilla.org/network/protocol;1?name=http"
+ ].getService(Ci.nsIHttpProtocolHandler);
+ return {
+ meta: {
+ // Currently interval is 1, but we could change it to a lower number
+ // when we have durations coming from js tracer.
+ interval: 1,
+ startTime: 0,
+ product: Services.appinfo.name,
+ importedFrom: "JS Tracer",
+ version: 28,
+ presymbolicated: true,
+ abi: Services.appinfo.XPCOMABI,
+ misc: httpHandler.misc,
+ oscpu: httpHandler.oscpu,
+ platform: httpHandler.platform,
+ processType: Services.appinfo.processType,
+ categories: [],
+ stackwalk: 0,
+ toolkit: Services.appinfo.widgetToolkit,
+ appBuildID: Services.appinfo.appBuildID,
+ sourceURL: Services.appinfo.sourceURL,
+ physicalCPUs: 0,
+ logicalCPUs: 0,
+ CPUName: "",
+ markerSchema: [],
+ },
+ libs: [],
+ pages: [],
+ threads: [],
+ processes: [],
+ };
+ }
+
+ /**
+ * Generate a thread object to be stored in the Gecko profile object.
+ */
+ #getEmptyThread() {
+ return {
+ processType: "default",
+ processStartupTime: 0,
+ processShutdownTime: null,
+ registerTime: 0,
+ unregisterTime: null,
+ pausedRanges: [],
+ name: "GeckoMain",
+ "eTLD+1": "JS Tracer",
+ isMainThread: true,
+ pid: Services.appinfo.processID,
+ tid: 0,
+ samples: {
+ schema: {
+ stack: 0,
+ time: 1,
+ eventDelay: 2,
+ },
+ data: [],
+ },
+ markers: {
+ schema: {
+ name: 0,
+ startTime: 1,
+ endTime: 2,
+ phase: 3,
+ category: 4,
+ data: 5,
+ },
+ data: [],
+ },
+ stackTable: {
+ schema: {
+ prefix: 0,
+ frame: 1,
+ },
+ data: [],
+ },
+ frameTable: {
+ schema: {
+ location: 0,
+ relevantForJS: 1,
+ innerWindowID: 2,
+ implementation: 3,
+ line: 4,
+ column: 5,
+ category: 6,
+ subcategory: 7,
+ },
+ data: [],
+ },
+ stringTable: [],
+ };
+ }
+
+ /**
+ * Record a new sample to be stored in the Gecko profile object.
+ *
+ * @param {Object} frame
+ * Object describing a frame with following attributes:
+ * - {String} name
+ * Human readable name for this frame.
+ * - {String} url
+ * URL of the running script.
+ * - {Number} lineNumber
+ * Line currently executing for this script.
+ * - {Number} columnNumber
+ * Column currently executing for this script.
+ * - {String} category
+ * Which JS implementation is being used for this frame: interpreter, baseline, ion or wasm.
+ * See Debugger.frame.implementation.
+ */
+ addSample(frame, depth) {
+ const currentDepth = this.#currentStack.length;
+ if (currentDepth == depth) {
+ // We are in the same depth and executing another frame. Replace the
+ // current frame with the new one.
+ this.#currentStack[currentDepth] = frame;
+ } else if (currentDepth < depth) {
+ // We are going deeper in the stack. Push the new frame.
+ this.#currentStack.push(frame);
+ } else {
+ // We are going back in the stack. Pop frames until we reach the right depth.
+ this.#currentStack.length = depth;
+ this.#currentStack[depth] = frame;
+ }
+
+ const stack = this.#currentStack.reduce((prefix, stackFrame) => {
+ const frameIndex = this.#getOrCreateFrame(stackFrame);
+ return this.#getOrCreateStack(frameIndex, prefix);
+ }, null);
+ this.#thread.samples.data.push([
+ stack,
+ // We put simply 1 sample (1ms) for each frame. We can change it in the
+ // future if we can get the duration of the frame.
+ this.#time++,
+ 0, // eventDelay
+ ]);
+ }
+
+ #getOrCreateFrame(frame) {
+ const { frameTable, stringTable } = this.#thread;
+ const frameString = `${frame.name}:${frame.url}:${frame.lineNumber}:${frame.columnNumber}:${frame.category}`;
+ let frameIndex = this.#frameMap.get(frameString);
+
+ if (frameIndex === undefined) {
+ frameIndex = frameTable.data.length;
+ const location = stringTable.length;
+ // Profiler frontend except a particular string to match the source URL:
+ // `functionName (http://script.url/:1234:1234)`
+ // https://github.com/firefox-devtools/profiler/blob/dab645b2db7e1b21185b286f96dd03b77f68f5c3/src/profile-logic/process-profile.js#L518
+ stringTable.push(
+ `${frame.name} (${frame.url}:${frame.lineNumber}:${frame.columnNumber})`
+ );
+
+ const category = this.#getOrCreateCategory(frame.category);
+
+ frameTable.data.push([
+ location,
+ true, // relevantForJS
+ 0, // innerWindowID
+ null, // implementation
+ frame.lineNumber, // line
+ frame.columnNumber, // column
+ category,
+ 0, // subcategory
+ ]);
+ this.#frameMap.set(frameString, frameIndex);
+ }
+
+ return frameIndex;
+ }
+
+ #getOrCreateStack(frameIndex, prefix) {
+ const { stackTable } = this.#thread;
+ const key = prefix === null ? `${frameIndex}` : `${frameIndex},${prefix}`;
+ let stack = this.#stackMap.get(key);
+
+ if (stack === undefined) {
+ stack = stackTable.data.length;
+ stackTable.data.push([prefix, frameIndex]);
+ this.#stackMap.set(key, stack);
+ }
+ return stack;
+ }
+
+ #getOrCreateCategory(category) {
+ const categories = this.#categories;
+ let categoryIndex = categories.findIndex(c => c.name === category);
+
+ if (categoryIndex === -1) {
+ categoryIndex = categories.length;
+ categories.push({
+ name: category,
+ color: PREDEFINED_COLORS[category] ?? DEFAULT_COLOR,
+ subcategories: ["Other"],
+ });
+ }
+ return categoryIndex;
+ }
+}
+
+exports.GeckoProfileCollector = GeckoProfileCollector;
diff --git a/devtools/server/actors/utils/inactive-property-helper.js b/devtools/server/actors/utils/inactive-property-helper.js
new file mode 100644
index 0000000000..759c2e6215
--- /dev/null
+++ b/devtools/server/actors/utils/inactive-property-helper.js
@@ -0,0 +1,1443 @@
+/* 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,
+ "CssLogic",
+ "resource://devtools/server/actors/inspector/css-logic.js",
+ true
+);
+
+const INACTIVE_CSS_ENABLED = Services.prefs.getBoolPref(
+ "devtools.inspector.inactive.css.enabled",
+ false
+);
+
+const TEXT_WRAP_BALANCE_LIMIT = Services.prefs.getIntPref(
+ "layout.css.text-wrap-balance.limit",
+ 10
+);
+
+const VISITED_MDN_LINK = "https://developer.mozilla.org/docs/Web/CSS/:visited";
+const VISITED_INVALID_PROPERTIES = allCssPropertiesExcept([
+ "all",
+ "color",
+ "background",
+ "background-color",
+ "border",
+ "border-color",
+ "border-bottom-color",
+ "border-left-color",
+ "border-right-color",
+ "border-top-color",
+ "border-block",
+ "border-block-color",
+ "border-block-start-color",
+ "border-block-end-color",
+ "border-inline",
+ "border-inline-color",
+ "border-inline-start-color",
+ "border-inline-end-color",
+ "column-rule",
+ "column-rule-color",
+ "outline",
+ "outline-color",
+ "text-decoration-color",
+ "text-emphasis-color",
+]);
+
+// Set of node names which are always treated as replaced elements:
+const REPLACED_ELEMENTS_NAMES = new Set([
+ "audio",
+ "br",
+ "button",
+ "canvas",
+ "embed",
+ "hr",
+ "iframe",
+ // Inputs are generally replaced elements. E.g. checkboxes and radios are replaced
+ // unless they have `appearance: none`. However unconditionally treating them
+ // as replaced is enough for our purpose here, and avoids extra complexity that
+ // will likely not be necessary in most cases.
+ "input",
+ "math",
+ "object",
+ "picture",
+ // Select is a replaced element if it has `size<=1` or no size specified, but
+ // unconditionally treating it as replaced is enough for our purpose here, and
+ // avoids extra complexity that will likely not be necessary in most cases.
+ "select",
+ "svg",
+ "textarea",
+ "video",
+]);
+
+const CUE_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://developer.mozilla.org/docs/Web/CSS/::cue";
+
+const HIGHLIGHT_PSEUDO_ELEMENTS_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#highlight-styling";
+const HIGHLIGHT_PSEUDO_ELEMENTS = [
+ "::highlight",
+ "::selection",
+ // Below are properties not yet implemented in Firefox (Bug 1694053)
+ "::grammar-error",
+ "::spelling-error",
+ "::target-text",
+];
+const REGEXP_HIGHLIGHT_PSEUDO_ELEMENTS = new RegExp(
+ `${HIGHLIGHT_PSEUDO_ELEMENTS.join("|")}`
+);
+
+const FIRST_LINE_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#first-line-styling";
+
+const FIRST_LETTER_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#first-letter-styling";
+
+const PLACEHOLDER_PSEUDO_ELEMENT_STYLING_SPEC_URL =
+ "https://www.w3.org/TR/css-pseudo-4/#placeholder-pseudo";
+
+class InactivePropertyHelper {
+ /**
+ * A list of rules for when CSS properties have no effect.
+ *
+ * In certain situations, CSS properties do not have any effect. A common
+ * example is trying to set a width on an inline element like a <span>.
+ *
+ * There are so many properties in CSS that it's difficult to remember which
+ * ones do and don't apply in certain situations. Some are straight-forward
+ * like `flex-wrap` only applying to an element that has `display:flex`.
+ * Others are less trivial like setting something other than a color on a
+ * `:visited` pseudo-class.
+ *
+ * This file contains "rules" in the form of objects with the following
+ * properties:
+ * {
+ * invalidProperties:
+ * Set of CSS property names that are inactive if the rule matches.
+ * when:
+ * The rule itself, a JS function used to identify the conditions
+ * indicating whether a property is valid or not.
+ * fixId:
+ * A Fluent id containing a suggested solution to the problem that is
+ * causing a property to be inactive.
+ * msgId:
+ * A Fluent id containing an error message explaining why a property is
+ * inactive in this situation.
+ * }
+ *
+ * If you add a new rule, also add a test for it in:
+ * server/tests/chrome/test_inspector-inactive-property-helper.html
+ *
+ * The main export is `isPropertyUsed()`, which can be used to check if a
+ * property is used or not, and why.
+ *
+ * NOTE: We should generally *not* add rules here for any CSS properties that
+ * inherit by default, because it's hard for us to know whether such
+ * properties are truly "inactive". Web developers might legitimately set
+ * such a property on any arbitrary element, in order to concisely establish
+ * the default property-value throughout that element's subtree. For example,
+ * consider the "list-style-*" properties, which inherit by default and which
+ * only have a rendering effect on elements with "display:list-item"
+ * (e.g. <li>). It might superficially seem like we could add a rule here to
+ * warn about usages of these properties on non-"list-item" elements, but we
+ * shouldn't actually warn about that. A web developer may legitimately
+ * prefer to set these properties on an arbitrary container element (e.g. an
+ * <ol> element, or even the <html> element) in order to concisely adjust the
+ * rendering of a whole list (or all the lists in a document).
+ */
+ get INVALID_PROPERTIES_VALIDATORS() {
+ return [
+ // Flex container property used on non-flex container.
+ {
+ invalidProperties: ["flex-direction", "flex-flow", "flex-wrap"],
+ when: () => !this.flexContainer,
+ fixId: "inactive-css-not-flex-container-fix",
+ msgId: "inactive-css-not-flex-container",
+ },
+ // Flex item property used on non-flex item.
+ {
+ invalidProperties: ["flex", "flex-basis", "flex-grow", "flex-shrink"],
+ when: () => !this.flexItem,
+ fixId: "inactive-css-not-flex-item-fix-2",
+ msgId: "inactive-css-not-flex-item",
+ },
+ // Grid container property used on non-grid container.
+ {
+ invalidProperties: [
+ "grid-auto-columns",
+ "grid-auto-flow",
+ "grid-auto-rows",
+ "grid-template",
+ "grid-template-areas",
+ "grid-template-columns",
+ "grid-template-rows",
+ "justify-items",
+ ],
+ when: () => !this.gridContainer,
+ fixId: "inactive-css-not-grid-container-fix",
+ msgId: "inactive-css-not-grid-container",
+ },
+ // Grid item property used on non-grid item.
+ {
+ invalidProperties: [
+ "grid-area",
+ "grid-column",
+ "grid-column-end",
+ "grid-column-start",
+ "grid-row",
+ "grid-row-end",
+ "grid-row-start",
+ "justify-self",
+ ],
+ when: () => !this.gridItem && !this.isAbsPosGridElement(),
+ fixId: "inactive-css-not-grid-item-fix-2",
+ msgId: "inactive-css-not-grid-item",
+ },
+ // Grid and flex item properties used on non-grid or non-flex item.
+ {
+ invalidProperties: ["align-self", "place-self", "order"],
+ when: () =>
+ !this.gridItem && !this.flexItem && !this.isAbsPosGridElement(),
+ fixId: "inactive-css-not-grid-or-flex-item-fix-3",
+ msgId: "inactive-css-not-grid-or-flex-item",
+ },
+ // Grid and flex container properties used on non-grid or non-flex container.
+ {
+ invalidProperties: [
+ "align-items",
+ "justify-content",
+ "place-content",
+ "place-items",
+ "row-gap",
+ // grid-*-gap are supported legacy shorthands for the corresponding *-gap properties.
+ // See https://drafts.csswg.org/css-align-3/#gap-legacy for more information.
+ "grid-row-gap",
+ ],
+ when: () => !this.gridContainer && !this.flexContainer,
+ fixId: "inactive-css-not-grid-or-flex-container-fix",
+ msgId: "inactive-css-not-grid-or-flex-container",
+ },
+ // align-content is special as align-content:baseline does have an effect on all
+ // grid items, flex items and table cells, regardless of what type of box they are.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1598730
+ {
+ invalidProperties: ["align-content"],
+ when: () =>
+ !this.style["align-content"].includes("baseline") &&
+ !this.gridContainer &&
+ !this.flexContainer,
+ fixId: "inactive-css-not-grid-or-flex-container-fix",
+ msgId: "inactive-css-not-grid-or-flex-container",
+ },
+ // column-gap and shorthands used on non-grid or non-flex or non-multi-col container.
+ {
+ invalidProperties: [
+ "column-gap",
+ "gap",
+ "grid-gap",
+ // grid-*-gap are supported legacy shorthands for the corresponding *-gap properties.
+ // See https://drafts.csswg.org/css-align-3/#gap-legacy for more information.
+ "grid-column-gap",
+ ],
+ when: () =>
+ !this.gridContainer && !this.flexContainer && !this.multiColContainer,
+ fixId:
+ "inactive-css-not-grid-or-flex-container-or-multicol-container-fix",
+ msgId: "inactive-css-not-grid-or-flex-container-or-multicol-container",
+ },
+ // Multi-column related properties used on non-multi-column container.
+ {
+ invalidProperties: [
+ "column-fill",
+ "column-rule",
+ "column-rule-color",
+ "column-rule-style",
+ "column-rule-width",
+ ],
+ when: () => !this.multiColContainer,
+ fixId: "inactive-css-not-multicol-container-fix",
+ msgId: "inactive-css-not-multicol-container",
+ },
+ // Inline properties used on non-inline-level elements.
+ {
+ invalidProperties: ["vertical-align"],
+ when: () =>
+ !this.isInlineLevel() && !this.isFirstLetter && !this.isFirstLine,
+ fixId: "inactive-css-not-inline-or-tablecell-fix",
+ msgId: "inactive-css-not-inline-or-tablecell",
+ },
+ // Writing mode properties used on ::first-line pseudo-element.
+ {
+ invalidProperties: ["direction", "text-orientation", "writing-mode"],
+ when: () => this.isFirstLine,
+ fixId: "learn-more",
+ msgId: "inactive-css-first-line-pseudo-element-not-supported",
+ learnMoreURL: FIRST_LINE_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ // Content modifying properties used on ::first-letter pseudo-element.
+ {
+ invalidProperties: ["content"],
+ when: () => this.isFirstLetter,
+ fixId: "learn-more",
+ msgId: "inactive-css-first-letter-pseudo-element-not-supported",
+ learnMoreURL: FIRST_LETTER_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ // Writing mode or inline properties used on ::placeholder pseudo-element.
+ {
+ invalidProperties: [
+ "baseline-source",
+ "direction",
+ "dominant-baseline",
+ "line-height",
+ "text-orientation",
+ "vertical-align",
+ "writing-mode",
+ // Below are properties not yet implemented in Firefox (Bug 1312611)
+ "alignment-baseline",
+ "baseline-shift",
+ "initial-letter",
+ "text-box-trim",
+ ],
+ when: () => {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::placeholder");
+ },
+ fixId: "learn-more",
+ msgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ learnMoreURL: PLACEHOLDER_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ // (max-|min-)width used on inline elements, table rows, or row groups.
+ {
+ invalidProperties: ["max-width", "min-width", "width"],
+ when: () =>
+ this.nonReplacedInlineBox ||
+ this.horizontalTableTrack ||
+ this.horizontalTableTrackGroup,
+ fixId: "inactive-css-non-replaced-inline-or-table-row-or-row-group-fix",
+ msgId: "inactive-css-property-because-of-display",
+ },
+ // (max-|min-)height used on inline elements, table columns, or column groups.
+ {
+ invalidProperties: ["max-height", "min-height", "height"],
+ when: () =>
+ this.nonReplacedInlineBox ||
+ this.verticalTableTrack ||
+ this.verticalTableTrackGroup,
+ fixId:
+ "inactive-css-non-replaced-inline-or-table-column-or-column-group-fix",
+ msgId: "inactive-css-property-because-of-display",
+ },
+ {
+ invalidProperties: ["display"],
+ when: () =>
+ this.isFloated &&
+ this.checkResolvedStyle("display", [
+ "inline",
+ "inline-block",
+ "inline-table",
+ "inline-flex",
+ "inline-grid",
+ "table-cell",
+ "table-row",
+ "table-row-group",
+ "table-header-group",
+ "table-footer-group",
+ "table-column",
+ "table-column-group",
+ "table-caption",
+ ]),
+ fixId: "inactive-css-not-display-block-on-floated-fix",
+ msgId: "inactive-css-not-display-block-on-floated",
+ },
+ // The property is impossible to override due to :visited restriction.
+ {
+ invalidProperties: VISITED_INVALID_PROPERTIES,
+ when: () => this.isVisitedRule(),
+ fixId: "learn-more",
+ msgId: "inactive-css-property-is-impossible-to-override-in-visited",
+ learnMoreURL: VISITED_MDN_LINK,
+ },
+ // top, right, bottom, left properties used on non positioned boxes.
+ {
+ invalidProperties: ["top", "right", "bottom", "left"],
+ when: () => !this.isPositioned,
+ fixId: "inactive-css-position-property-on-unpositioned-box-fix",
+ msgId: "inactive-css-position-property-on-unpositioned-box",
+ },
+ // z-index property used on non positioned boxes that are not grid/flex items.
+ {
+ invalidProperties: ["z-index"],
+ when: () => !this.isPositioned && !this.gridItem && !this.flexItem,
+ fixId: "inactive-css-position-property-on-unpositioned-box-fix",
+ msgId: "inactive-css-position-property-on-unpositioned-box",
+ },
+ // text-overflow property used on elements for which 'overflow' is set to 'visible'
+ // (the initial value) in the inline axis. Note that this validator only checks if
+ // 'overflow-inline' computes to 'visible' on the element.
+ // In theory, we should also be checking if the element is a block as this doesn't
+ // normally work on inline element. However there are many edge cases that made it
+ // impossible for the JS code to determine whether the type of box would support
+ // text-overflow. So, rather than risking to show invalid warnings, we decided to
+ // only warn when 'overflow-inline: visible' was set. There is more information
+ // about this in this discussion https://phabricator.services.mozilla.com/D62407 and
+ // on the bug https://bugzilla.mozilla.org/show_bug.cgi?id=1551578
+ {
+ invalidProperties: ["text-overflow"],
+ when: () => this.checkComputedStyle("overflow-inline", ["visible"]),
+ fixId: "inactive-text-overflow-when-no-overflow-fix",
+ msgId: "inactive-text-overflow-when-no-overflow",
+ },
+ // margin properties used on table internal elements.
+ {
+ invalidProperties: [
+ "margin",
+ "margin-block",
+ "margin-block-end",
+ "margin-block-start",
+ "margin-bottom",
+ "margin-inline",
+ "margin-inline-end",
+ "margin-inline-start",
+ "margin-left",
+ "margin-right",
+ "margin-top",
+ ],
+ when: () => this.internalTableElement,
+ fixId: "inactive-css-not-for-internal-table-elements-fix",
+ msgId: "inactive-css-not-for-internal-table-elements",
+ },
+ // padding properties used on table internal elements except table cells.
+ {
+ invalidProperties: [
+ "padding",
+ "padding-block",
+ "padding-block-end",
+ "padding-block-start",
+ "padding-bottom",
+ "padding-inline",
+ "padding-inline-end",
+ "padding-inline-start",
+ "padding-left",
+ "padding-right",
+ "padding-top",
+ ],
+ when: () =>
+ this.internalTableElement &&
+ !this.checkComputedStyle("display", ["table-cell"]),
+ fixId:
+ "inactive-css-not-for-internal-table-elements-except-table-cells-fix",
+ msgId:
+ "inactive-css-not-for-internal-table-elements-except-table-cells",
+ },
+ // table-layout used on non-table elements.
+ {
+ invalidProperties: ["table-layout"],
+ when: () =>
+ !this.checkComputedStyle("display", ["table", "inline-table"]),
+ fixId: "inactive-css-not-table-fix",
+ msgId: "inactive-css-not-table",
+ },
+ // empty-cells property used on non-table-cell elements.
+ {
+ invalidProperties: ["empty-cells"],
+ when: () => !this.checkComputedStyle("display", ["table-cell"]),
+ fixId: "inactive-css-not-table-cell-fix",
+ msgId: "inactive-css-not-table-cell",
+ },
+ // scroll-padding-* properties used on non-scrollable elements.
+ {
+ invalidProperties: [
+ "scroll-padding",
+ "scroll-padding-top",
+ "scroll-padding-right",
+ "scroll-padding-bottom",
+ "scroll-padding-left",
+ "scroll-padding-block",
+ "scroll-padding-block-end",
+ "scroll-padding-block-start",
+ "scroll-padding-inline",
+ "scroll-padding-inline-end",
+ "scroll-padding-inline-start",
+ ],
+ when: () => !this.isScrollContainer,
+ fixId: "inactive-scroll-padding-when-not-scroll-container-fix",
+ msgId: "inactive-scroll-padding-when-not-scroll-container",
+ },
+ // border-image properties used on internal table with border collapse.
+ {
+ invalidProperties: [
+ "border-image",
+ "border-image-outset",
+ "border-image-repeat",
+ "border-image-slice",
+ "border-image-source",
+ "border-image-width",
+ ],
+ when: () =>
+ this.internalTableElement &&
+ this.checkTableParentHasBorderCollapsed(),
+ fixId: "inactive-css-border-image-fix",
+ msgId: "inactive-css-border-image",
+ },
+ // width & height properties used on ruby elements.
+ {
+ invalidProperties: [
+ "height",
+ "min-height",
+ "max-height",
+ "width",
+ "min-width",
+ "max-width",
+ ],
+ when: () => this.checkComputedStyle("display", ["ruby", "ruby-text"]),
+ fixId: "inactive-css-ruby-element-fix",
+ msgId: "inactive-css-ruby-element",
+ },
+ // text-wrap: balance; used on elements exceeding the threshold line number
+ {
+ invalidProperties: ["text-wrap"],
+ when: () => {
+ if (!this.checkComputedStyle("text-wrap", ["balance"])) {
+ return false;
+ }
+ const blockLineCounts = InspectorUtils.getBlockLineCounts(this.node);
+ // We only check the number of lines within the first block
+ // because the text-wrap: balance; property only applies to
+ // the first block. And fragmented elements (with multiple
+ // blocks) are excluded from line balancing for the time being.
+ return (
+ blockLineCounts && blockLineCounts[0] > TEXT_WRAP_BALANCE_LIMIT
+ );
+ },
+ fixId: "inactive-css-text-wrap-balance-lines-exceeded-fix",
+ msgId: "inactive-css-text-wrap-balance-lines-exceeded",
+ lineCount: TEXT_WRAP_BALANCE_LIMIT,
+ },
+ // text-wrap: balance; used on fragmented elements
+ {
+ invalidProperties: ["text-wrap"],
+ when: () => {
+ if (!this.checkComputedStyle("text-wrap", ["balance"])) {
+ return false;
+ }
+ const blockLineCounts = InspectorUtils.getBlockLineCounts(this.node);
+ const isFragmented = blockLineCounts && blockLineCounts.length > 1;
+ return isFragmented;
+ },
+ fixId: "inactive-css-text-wrap-balance-fragmented-fix",
+ msgId: "inactive-css-text-wrap-balance-fragmented",
+ },
+ ];
+ }
+
+ /**
+ * A list of rules for when CSS properties have no effect,
+ * based on an allow list of properties.
+ * We're setting this as a different array than INVALID_PROPERTIES_VALIDATORS as we
+ * need to check every properties, which we don't do for invalid properties ( see check
+ * on this.invalidProperties).
+ *
+ * This file contains "rules" in the form of objects with the following
+ * properties:
+ * {
+ * acceptedProperties:
+ * Array of CSS property names that are the only one accepted if the rule matches.
+ * when:
+ * The rule itself, a JS function used to identify the conditions
+ * indicating whether a property is valid or not.
+ * fixId:
+ * A Fluent id containing a suggested solution to the problem that is
+ * causing a property to be inactive.
+ * msgId:
+ * A Fluent id containing an error message explaining why a property is
+ * inactive in this situation.
+ * }
+ *
+ * If you add a new rule, also add a test for it in:
+ * server/tests/chrome/test_inspector-inactive-property-helper.html
+ *
+ * The main export is `isPropertyUsed()`, which can be used to check if a
+ * property is used or not, and why.
+ */
+ ACCEPTED_PROPERTIES_VALIDATORS = [
+ // Constrained set of properties on highlight pseudo-elements
+ {
+ acceptedProperties: new Set([
+ // At the moment, for shorthand we don't look into each properties it covers,
+ // and so, although `background` might hold inactive values (e.g. background-image)
+ // we don't want to mark it as inactive if it sets a background-color (e.g. background: red).
+ "background",
+ "background-color",
+ "color",
+ "text-decoration",
+ "text-decoration-color",
+ "text-decoration-line",
+ "text-decoration-style",
+ "text-decoration-thickness",
+ "text-shadow",
+ "text-underline-offset",
+ "text-underline-position",
+ "-webkit-text-fill-color",
+ "-webkit-text-stroke-color",
+ "-webkit-text-stroke-width",
+ "-webkit-text-stroke",
+ ]),
+ when: () => {
+ const { selectorText } = this.cssRule;
+ return (
+ selectorText && REGEXP_HIGHLIGHT_PSEUDO_ELEMENTS.test(selectorText)
+ );
+ },
+ msgId: "inactive-css-highlight-pseudo-elements-not-supported",
+ fixId: "learn-more",
+ learnMoreURL: HIGHLIGHT_PSEUDO_ELEMENTS_STYLING_SPEC_URL,
+ },
+ // Constrained set of properties on ::cue pseudo-element
+ //
+ // Note that Gecko doesn't yet support the ::cue() pseudo-element
+ // taking a selector as argument. The properties accecpted by that
+ // partly differ from the ones accepted by the ::cue pseudo-element.
+ // See https://w3c.github.io/webvtt/#ref-for-selectordef-cue-selector⑧.
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=865395 and its
+ // dependencies for the implementation status.
+ {
+ acceptedProperties: new Set([
+ "background",
+ "background-attachment",
+ // The WebVTT spec. currently only allows all properties covered by
+ // the `background` shorthand and `background-blend-mode` is not
+ // part of that, though Gecko does support it, anyway.
+ // Therefore, there's also an issue pending to add it (and others)
+ // to the spec. See https://github.com/w3c/webvtt/issues/518.
+ "background-blend-mode",
+ "background-clip",
+ "background-color",
+ "background-image",
+ "background-origin",
+ "background-position",
+ "background-position-x",
+ "background-position-y",
+ "background-repeat",
+ "background-size",
+ "color",
+ "font",
+ "font-family",
+ "font-size",
+ "font-stretch",
+ "font-style",
+ "font-variant",
+ "font-variant-alternates",
+ "font-variant-caps",
+ "font-variant-east-asian",
+ "font-variant-ligatures",
+ "font-variant-numeric",
+ "font-variant-position",
+ "font-weight",
+ "line-height",
+ "opacity",
+ "outline",
+ "outline-color",
+ "outline-offset",
+ "outline-style",
+ "outline-width",
+ "ruby-position",
+ "text-combine-upright",
+ "text-decoration",
+ "text-decoration-color",
+ "text-decoration-line",
+ "text-decoration-style",
+ "text-decoration-thickness",
+ "text-shadow",
+ "visibility",
+ "white-space",
+ ]),
+ when: () => {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::cue");
+ },
+ msgId: "inactive-css-cue-pseudo-element-not-supported",
+ fixId: "learn-more",
+ learnMoreURL: CUE_PSEUDO_ELEMENT_STYLING_SPEC_URL,
+ },
+ ];
+
+ /**
+ * Get a list of unique CSS property names for which there are checks
+ * for used/unused state.
+ *
+ * @return {Set}
+ * List of CSS properties
+ */
+ get invalidProperties() {
+ if (!this._invalidProperties) {
+ const allProps = this.INVALID_PROPERTIES_VALIDATORS.map(
+ v => v.invalidProperties
+ ).flat();
+ this._invalidProperties = new Set(allProps);
+ }
+
+ return this._invalidProperties;
+ }
+
+ /**
+ * Is this CSS property having any effect on this element?
+ *
+ * @param {DOMNode} el
+ * The DOM element.
+ * @param {Style} elStyle
+ * The computed style for this DOMNode.
+ * @param {DOMRule} cssRule
+ * The CSS rule the property is defined in.
+ * @param {String} property
+ * The CSS property name.
+ *
+ * @return {Object} object
+ * @return {String} object.display
+ * The element computed display value.
+ * @return {String} object.fixId
+ * A Fluent id containing a suggested solution to the problem that is
+ * causing a property to be inactive.
+ * @return {String} object.msgId
+ * A Fluent id containing an error message explaining why a property
+ * is inactive in this situation.
+ * @return {String} object.property
+ * The inactive property name.
+ * @return {String} object.learnMoreURL
+ * An optional link if we need to open an other link than
+ * the default MDN property one.
+ * @return {Boolean} object.used
+ * true if the property is used.
+ */
+ isPropertyUsed(el, elStyle, cssRule, property) {
+ // Assume the property is used when the Inactive CSS pref is not enabled
+ if (!INACTIVE_CSS_ENABLED) {
+ return { used: true };
+ }
+
+ let fixId = "";
+ let msgId = "";
+ let learnMoreURL = null;
+ let lineCount = null;
+ let used = true;
+
+ const someFn = validator => {
+ // First check if this rule cares about this property.
+ let isRuleConcerned = false;
+
+ if (validator.invalidProperties) {
+ isRuleConcerned = validator.invalidProperties.includes(property);
+ } else if (validator.acceptedProperties) {
+ isRuleConcerned = !validator.acceptedProperties.has(property);
+ }
+
+ if (!isRuleConcerned) {
+ return false;
+ }
+
+ this.select(el, elStyle, cssRule, property);
+
+ // And then run the validator, gathering the error message if the
+ // validator passes.
+ if (validator.when()) {
+ fixId = validator.fixId;
+ msgId = validator.msgId;
+ learnMoreURL = validator.learnMoreURL;
+ lineCount = validator.lineCount;
+ used = false;
+
+ // We can bail out as soon as a validator reported an issue.
+ return true;
+ }
+
+ return false;
+ };
+
+ // First run the accepted properties validators
+ const isNotAccepted = this.ACCEPTED_PROPERTIES_VALIDATORS.some(someFn);
+
+ // If the property is not in the list of properties to check and there was no issues
+ // in the accepted properties validators, assume the property is used.
+ if (!isNotAccepted && !this.invalidProperties.has(property)) {
+ this.unselect();
+ return { used: true };
+ }
+
+ // Otherwise, if there was no issue from the accepted properties validators,
+ // run the invalid properties validators.
+ if (!isNotAccepted) {
+ this.INVALID_PROPERTIES_VALIDATORS.some(someFn);
+ }
+
+ this.unselect();
+
+ // Accessing elStyle might throws, we wrap it in a try/catch block to avoid test
+ // failures.
+ let display;
+ try {
+ display = elStyle ? elStyle.display : null;
+ } catch (e) {}
+
+ return {
+ display,
+ fixId,
+ msgId,
+ property,
+ learnMoreURL,
+ lineCount,
+ used,
+ };
+ }
+
+ /**
+ * Focus on a node.
+ *
+ * @param {DOMNode} node
+ * Node to focus on.
+ */
+ select(node, style, cssRule, property) {
+ this._node = node;
+ this._cssRule = cssRule;
+ this._property = property;
+ this._style = style;
+ }
+
+ /**
+ * Clear references to avoid leaks.
+ */
+ unselect() {
+ this._node = null;
+ this._cssRule = null;
+ this._property = null;
+ this._style = null;
+ }
+
+ /**
+ * Provide a public reference to node.
+ */
+ get node() {
+ return this._node;
+ }
+
+ /**
+ * Cache and provide node's computed style.
+ */
+ get style() {
+ return this._style;
+ }
+
+ /**
+ * Provide a public reference to the css rule.
+ */
+ get cssRule() {
+ return this._cssRule;
+ }
+
+ /**
+ * Check if the current node's propName is set to one of the values passed in
+ * the values array.
+ *
+ * @param {String} propName
+ * Property name to check.
+ * @param {Array} values
+ * Values to compare against.
+ */
+ checkComputedStyle(propName, values) {
+ if (!this.style) {
+ return false;
+ }
+ return values.some(value => this.style[propName] === value);
+ }
+
+ /**
+ * Check if a rule's propName is set to one of the values passed in the values
+ * array.
+ *
+ * @param {String} propName
+ * Property name to check.
+ * @param {Array} values
+ * Values to compare against.
+ */
+ checkResolvedStyle(propName, values) {
+ if (!(this.cssRule && this.cssRule.style)) {
+ return false;
+ }
+ const { style } = this.cssRule;
+
+ return values.some(value => style[propName] === value);
+ }
+
+ /**
+ * Check if the current node is an inline-level box.
+ */
+ isInlineLevel() {
+ return this.checkComputedStyle("display", [
+ "inline",
+ "inline-block",
+ "inline-table",
+ "inline-flex",
+ "inline-grid",
+ "table-cell",
+ "table-row",
+ "table-row-group",
+ "table-header-group",
+ "table-footer-group",
+ ]);
+ }
+
+ /**
+ * Check if the current node is a flex container i.e. a node that has a style
+ * of `display:flex` or `display:inline-flex`.
+ */
+ get flexContainer() {
+ return this.checkComputedStyle("display", ["flex", "inline-flex"]);
+ }
+
+ /**
+ * Check if the current node is a flex item.
+ */
+ get flexItem() {
+ return this.isFlexItem(this.node);
+ }
+
+ /**
+ * Check if the current node is a grid container i.e. a node that has a style
+ * of `display:grid` or `display:inline-grid`.
+ */
+ get gridContainer() {
+ return this.checkComputedStyle("display", ["grid", "inline-grid"]);
+ }
+
+ /**
+ * Check if the current node is a grid item.
+ */
+ get gridItem() {
+ return this.isGridItem(this.node);
+ }
+
+ /**
+ * Check if the current node is a multi-column container, i.e. a node element whose
+ * `column-width` or `column-count` property is not `auto`.
+ */
+ get multiColContainer() {
+ const autoColumnWidth = this.checkComputedStyle("column-width", ["auto"]);
+ const autoColumnCount = this.checkComputedStyle("column-count", ["auto"]);
+
+ return !autoColumnWidth || !autoColumnCount;
+ }
+
+ /**
+ * Check if the current node is a table row.
+ */
+ get tableRow() {
+ return this.style && this.style.display === "table-row";
+ }
+
+ /**
+ * Check if the current node is a table column.
+ */
+ get tableColumn() {
+ return this.style && this.style.display === "table-column";
+ }
+
+ /**
+ * Check if the current node is an internal table element.
+ */
+ get internalTableElement() {
+ return this.checkComputedStyle("display", [
+ "table-cell",
+ "table-row",
+ "table-row-group",
+ "table-header-group",
+ "table-footer-group",
+ "table-column",
+ "table-column-group",
+ ]);
+ }
+
+ /**
+ * Check if the current node is a horizontal table track. That is: either a table row
+ * displayed in horizontal writing mode, or a table column displayed in vertical writing
+ * mode.
+ */
+ get horizontalTableTrack() {
+ if (!this.tableRow && !this.tableColumn) {
+ return false;
+ }
+
+ const tableTrackParent = this.getTableTrackParent();
+
+ return this.hasVerticalWritingMode(tableTrackParent)
+ ? this.tableColumn
+ : this.tableRow;
+ }
+
+ /**
+ * Check if the current node is a vertical table track. That is: either a table row
+ * displayed in vertical writing mode, or a table column displayed in horizontal writing
+ * mode.
+ */
+ get verticalTableTrack() {
+ if (!this.tableRow && !this.tableColumn) {
+ return false;
+ }
+
+ const tableTrackParent = this.getTableTrackParent();
+
+ return this.hasVerticalWritingMode(tableTrackParent)
+ ? this.tableRow
+ : this.tableColumn;
+ }
+
+ /**
+ * Check if the current node is a row group.
+ */
+ get rowGroup() {
+ return this.isRowGroup(this.node);
+ }
+
+ /**
+ * Check if the current node is a table column group.
+ */
+ get columnGroup() {
+ return this.isColumnGroup(this.node);
+ }
+
+ /**
+ * Check if the current node is a horizontal table track group. That is: either a table
+ * row group displayed in horizontal writing mode, or a table column group displayed in
+ * vertical writing mode.
+ */
+ get horizontalTableTrackGroup() {
+ if (!this.rowGroup && !this.columnGroup) {
+ return false;
+ }
+
+ const tableTrackParent = this.getTableTrackParent(true);
+ const isVertical = this.hasVerticalWritingMode(tableTrackParent);
+
+ const isHorizontalRowGroup = this.rowGroup && !isVertical;
+ const isHorizontalColumnGroup = this.columnGroup && isVertical;
+
+ return isHorizontalRowGroup || isHorizontalColumnGroup;
+ }
+
+ /**
+ * Check if the current node is a vertical table track group. That is: either a table row
+ * group displayed in vertical writing mode, or a table column group displayed in
+ * horizontal writing mode.
+ */
+ get verticalTableTrackGroup() {
+ if (!this.rowGroup && !this.columnGroup) {
+ return false;
+ }
+
+ const tableTrackParent = this.getTableTrackParent(true);
+ const isVertical = this.hasVerticalWritingMode(tableTrackParent);
+
+ const isVerticalRowGroup = this.rowGroup && isVertical;
+ const isVerticalColumnGroup = this.columnGroup && !isVertical;
+
+ return isVerticalRowGroup || isVerticalColumnGroup;
+ }
+
+ /**
+ * Returns whether this element uses CSS layout.
+ */
+ get hasCssLayout() {
+ return !this.isSvg && !this.isMathMl;
+ }
+
+ /**
+ * Check if the current node is a non-replaced CSS inline box.
+ */
+ get nonReplacedInlineBox() {
+ return (
+ this.hasCssLayout &&
+ this.nonReplaced &&
+ this.style &&
+ this.style.display === "inline"
+ );
+ }
+
+ /**
+ * Check if the current selector refers to a ::first-letter pseudo-element
+ */
+ get isFirstLetter() {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::first-letter");
+ }
+
+ /**
+ * Check if the current selector refers to a ::first-line pseudo-element
+ */
+ get isFirstLine() {
+ const { selectorText } = this.cssRule;
+ return selectorText && selectorText.includes("::first-line");
+ }
+
+ /**
+ * Check if the current node is a non-replaced element. See `replaced()` for
+ * a description of what a replaced element is.
+ */
+ get nonReplaced() {
+ return !this.replaced;
+ }
+
+ /**
+ * Check if the current node is an absolutely-positioned element.
+ */
+ get isAbsolutelyPositioned() {
+ return this.checkComputedStyle("position", ["absolute", "fixed"]);
+ }
+
+ /**
+ * Check if the current node is positioned (i.e. its position property has a value other
+ * than static).
+ */
+ get isPositioned() {
+ return this.checkComputedStyle("position", [
+ "relative",
+ "absolute",
+ "fixed",
+ "sticky",
+ ]);
+ }
+
+ /**
+ * Check if the current node is floated
+ */
+ get isFloated() {
+ return this.style && this.style.cssFloat !== "none";
+ }
+
+ /**
+ * Check if the current node is scrollable
+ */
+ get isScrollContainer() {
+ // If `overflow` doesn't contain the values `visible` or `clip`, it is a scroll container.
+ // While `hidden` doesn't allow scrolling via a user interaction, the element can
+ // still be scrolled programmatically.
+ // See https://www.w3.org/TR/css-overflow-3/#overflow-properties.
+ const overflow = computedStyle(this.node).overflow;
+ // `overflow` is a shorthand for `overflow-x` and `overflow-y`
+ // (and with that also for `overflow-inline` and `overflow-block`),
+ // so may hold two values.
+ const overflowValues = overflow.split(" ");
+ return !(
+ overflowValues.includes("visible") || overflowValues.includes("clip")
+ );
+ }
+
+ /**
+ * Check if the current node is a replaced element i.e. an element with
+ * content that will be replaced e.g. <img>, <audio>, <video> or <object>
+ * elements.
+ */
+ get replaced() {
+ if (REPLACED_ELEMENTS_NAMES.has(this.localName)) {
+ return true;
+ }
+
+ // img tags are replaced elements only when the image has finished loading.
+ if (this.localName === "img" && this.node.complete) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Return the current node's localName.
+ *
+ * @returns {String}
+ */
+ get localName() {
+ return this.node.localName;
+ }
+
+ /**
+ * Return whether the node is a MathML element.
+ */
+ get isMathMl() {
+ return this.node.namespaceURI === "http://www.w3.org/1998/Math/MathML";
+ }
+
+ /**
+ * Return whether the node is an SVG element.
+ */
+ get isSvg() {
+ return this.node.namespaceURI === "http://www.w3.org/2000/svg";
+ }
+
+ /**
+ * Check if the current node is an absolutely-positioned grid element.
+ * See: https://drafts.csswg.org/css-grid/#abspos-items
+ *
+ * @return {Boolean} whether or not the current node is absolutely-positioned by a
+ * grid container.
+ */
+ isAbsPosGridElement() {
+ if (!this.isAbsolutelyPositioned) {
+ return false;
+ }
+
+ const containingBlock = this.getContainingBlock();
+
+ return containingBlock !== null && this.isGridContainer(containingBlock);
+ }
+
+ /**
+ * Check if a node is a flex item.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isFlexItem(node) {
+ return !!node.parentFlexElement;
+ }
+
+ /**
+ * Check if a node is a flex container.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isFlexContainer(node) {
+ return !!node.getAsFlexContainer();
+ }
+
+ /**
+ * Check if a node is a grid container.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isGridContainer(node) {
+ return node.hasGridFragments();
+ }
+
+ /**
+ * Check if a node is a grid item.
+ *
+ * @param {DOMNode} node
+ * The node to check.
+ */
+ isGridItem(node) {
+ return !!this.getParentGridElement(this.node);
+ }
+
+ isVisitedRule() {
+ if (!CssLogic.hasVisitedState(this.node)) {
+ return false;
+ }
+
+ const selectors = CssLogic.getSelectors(this.cssRule);
+ if (!selectors.some(s => s.endsWith(":visited"))) {
+ return false;
+ }
+
+ const { bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(
+ this.node
+ );
+
+ for (let i = 0; i < selectors.length; i++) {
+ if (
+ !selectors[i].endsWith(":visited") &&
+ this.cssRule.selectorMatchesElement(i, bindingElement, pseudo, true)
+ ) {
+ // Match non :visited selector.
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Return the current node's ancestor that generates its containing block.
+ */
+ getContainingBlock() {
+ return this.node ? InspectorUtils.containingBlockOf(this.node) : null;
+ }
+
+ getParentGridElement(node) {
+ // The documentElement can't be a grid item, only a container, so bail out.
+ if (node.flattenedTreeParentNode === node.ownerDocument) {
+ return null;
+ }
+
+ if (node.nodeType === node.ELEMENT_NODE) {
+ const display = this.style ? this.style.display : null;
+
+ if (!display || display === "none" || display === "contents") {
+ // Doesn't generate a box, not a grid item.
+ return null;
+ }
+ if (this.isAbsolutelyPositioned) {
+ // Out of flow, not a grid item.
+ return null;
+ }
+ } else if (node.nodeType !== node.TEXT_NODE) {
+ return null;
+ }
+
+ for (
+ let p = node.flattenedTreeParentNode;
+ p;
+ p = p.flattenedTreeParentNode
+ ) {
+ if (this.isGridContainer(p)) {
+ // It's a grid item!
+ return p;
+ }
+
+ const style = computedStyle(p, node.ownerGlobal);
+ const display = style.display;
+
+ if (display !== "contents") {
+ return null; // Not a grid item, for sure.
+ }
+ // display: contents, walk to the parent
+ }
+ return null;
+ }
+
+ isRowGroup(node) {
+ const style = node === this.node ? this.style : computedStyle(node);
+
+ return (
+ style &&
+ (style.display === "table-row-group" ||
+ style.display === "table-header-group" ||
+ style.display === "table-footer-group")
+ );
+ }
+
+ isColumnGroup(node) {
+ const style = node === this.node ? this.style : computedStyle(node);
+
+ return style && style.display === "table-column-group";
+ }
+
+ /**
+ * Check if the given node's writing mode is vertical
+ */
+ hasVerticalWritingMode(node) {
+ // Only 'horizontal-tb' has a horizontal writing mode.
+ // See https://drafts.csswg.org/css-writing-modes-4/#propdef-writing-mode
+ return computedStyle(node).writingMode !== "horizontal-tb";
+ }
+
+ /**
+ * Assuming the current element is a table track (row or column) or table track group,
+ * get the parent table.
+ * This is either going to be the table element if there is one, or the parent element.
+ * If the current element is not a table track, this returns the current element.
+ *
+ * @param {Boolean} isGroup
+ * Whether the element is a table track group, instead of a table track.
+ * @return {DOMNode}
+ * The parent table, the parent element, or the element itself.
+ */
+ getTableTrackParent(isGroup) {
+ let current = this.node.parentNode;
+
+ // Skip over unrendered elements.
+ while (computedStyle(current).display === "contents") {
+ current = current.parentNode;
+ }
+
+ // Skip over groups if the initial element wasn't already one.
+ if (!isGroup && (this.isRowGroup(current) || this.isColumnGroup(current))) {
+ current = current.parentNode;
+ }
+
+ // Once more over unrendered elements above the group.
+ while (computedStyle(current).display === "contents") {
+ current = current.parentNode;
+ }
+
+ return current;
+ }
+
+ /**
+ * Get the parent table element of the current element.
+ *
+ * @return {DOMNode|null}
+ * The closest table element or null if there are none.
+ */
+ getTableParent() {
+ let current = this.node.parentNode;
+
+ // Find the table parent
+ while (current && computedStyle(current).display !== "table") {
+ current = current.parentNode;
+
+ // If we reached the document element, stop.
+ if (current == this.node.ownerDocument.documentElement) {
+ return null;
+ }
+ }
+
+ return current;
+ }
+
+ /**
+ * Assuming the current element is an internal table element,
+ * check wether its parent table element has `border-collapse` set to `collapse`.
+ *
+ * @returns {Boolean}
+ */
+ checkTableParentHasBorderCollapsed() {
+ const parent = this.getTableParent();
+ if (!parent) {
+ return false;
+ }
+ return computedStyle(parent).borderCollapse === "collapse";
+ }
+}
+
+/**
+ * Returns all CSS property names except given properties.
+ *
+ * @param {Array} - propertiesToIgnore
+ * Array of property ignored.
+ * @return {Array}
+ * Array of all CSS property name except propertiesToIgnore.
+ */
+function allCssPropertiesExcept(propertiesToIgnore) {
+ const properties = new Set(
+ InspectorUtils.getCSSPropertyNames({ includeAliases: true })
+ );
+
+ for (const name of propertiesToIgnore) {
+ properties.delete(name);
+ }
+
+ return [...properties];
+}
+
+/**
+ * Helper for getting an element's computed styles.
+ *
+ * @param {DOMNode} node
+ * The node to get the styles for.
+ * @param {Window} window
+ * Optional window object. If omitted, will get the node's window.
+ * @return {Object}
+ */
+function computedStyle(node, window = node.ownerGlobal) {
+ return window.getComputedStyle(node);
+}
+
+const inactivePropertyHelper = new InactivePropertyHelper();
+
+// The only public method from this module is `isPropertyUsed`.
+exports.isPropertyUsed = inactivePropertyHelper.isPropertyUsed.bind(
+ inactivePropertyHelper
+);
diff --git a/devtools/server/actors/utils/logEvent.js b/devtools/server/actors/utils/logEvent.js
new file mode 100644
index 0000000000..88b166619e
--- /dev/null
+++ b/devtools/server/actors/utils/logEvent.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";
+
+const {
+ formatDisplayName,
+} = require("resource://devtools/server/actors/frame.js");
+const {
+ TYPES,
+ getResourceWatcher,
+} = require("resource://devtools/server/actors/resources/index.js");
+
+// Get a string message to display when a frame evaluation throws.
+function getThrownMessage(completion) {
+ try {
+ if (completion.throw.getOwnPropertyDescriptor) {
+ return completion.throw.getOwnPropertyDescriptor("message").value;
+ } else if (completion.toString) {
+ return completion.toString();
+ }
+ } catch (ex) {
+ // ignore
+ }
+ return "Unknown exception";
+}
+module.exports.getThrownMessage = getThrownMessage;
+
+function logEvent({ threadActor, frame, level, expression, bindings }) {
+ const { sourceActor, line, column } =
+ threadActor.sourcesManager.getFrameLocation(frame);
+ const displayName = formatDisplayName(frame);
+
+ // TODO remove this branch when (#1592584) lands (#1609540)
+ if (isWorker) {
+ threadActor._parent._consoleActor.evaluateJS({
+ text: `console.log(...${expression})`,
+ bindings: { displayName, ...bindings },
+ url: sourceActor.url,
+ lineNumber: line,
+ disableBreaks: true,
+ });
+
+ return undefined;
+ }
+
+ let completion;
+ // Ensure disabling all types of breakpoints for all sources while evaluating the log points
+ threadActor.insideClientEvaluation = { disableBreaks: true };
+ try {
+ completion = frame.evalWithBindings(
+ expression,
+ {
+ displayName,
+ ...bindings,
+ },
+ { hideFromDebugger: true }
+ );
+ } finally {
+ threadActor.insideClientEvaluation = null;
+ }
+
+ let value;
+ if (!completion) {
+ // The evaluation was killed (possibly by the slow script dialog).
+ value = ["Evaluation failed"];
+ } else if ("return" in completion) {
+ value = completion.return;
+ } else {
+ value = [getThrownMessage(completion)];
+ level = `${level}Error`;
+ }
+
+ if (value && typeof value.unsafeDereference === "function") {
+ value = value.unsafeDereference();
+ }
+
+ const targetActor = threadActor._parent;
+ const message = {
+ filename: sourceActor.url,
+ lineNumber: line,
+ columnNumber: column,
+ arguments: value,
+ level,
+ timeStamp: ChromeUtils.dateNow(),
+ chromeContext:
+ targetActor.actorID &&
+ /conn\d+\.parentProcessTarget\d+/.test(targetActor.actorID),
+ // The 'prepareConsoleMessageForRemote' method in webconsoleActor expects internal source ID,
+ // thus we can't set sourceId directly to sourceActorID.
+ sourceId: sourceActor.internalSourceId,
+ };
+
+ // Note that only WindowGlobalTarget actor support resource watcher
+ // This is still missing for worker and content processes
+ const consoleMessageWatcher = getResourceWatcher(
+ targetActor,
+ TYPES.CONSOLE_MESSAGE
+ );
+ if (consoleMessageWatcher) {
+ consoleMessageWatcher.emitMessages([message]);
+ } else {
+ // Bug 1642296: Once we enable ConsoleMessage resource on the server, we should remove onConsoleAPICall
+ // from the WebConsoleActor, and only support the ConsoleMessageWatcher codepath.
+ targetActor._consoleActor.onConsoleAPICall(message);
+ }
+
+ return undefined;
+}
+
+module.exports.logEvent = logEvent;
diff --git a/devtools/server/actors/utils/make-debugger.js b/devtools/server/actors/utils/make-debugger.js
new file mode 100644
index 0000000000..40c28f01b2
--- /dev/null
+++ b/devtools/server/actors/utils/make-debugger.js
@@ -0,0 +1,122 @@
+/* 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 Debugger = require("Debugger");
+
+const {
+ reportException,
+} = require("resource://devtools/shared/DevToolsUtils.js");
+
+/**
+ * Multiple actors that use a |Debugger| instance come in a few versions, each
+ * with a different set of debuggees. One version for content tabs (globals
+ * within a tab), one version for chrome debugging (all globals), and sometimes
+ * a third version for addon debugging (chrome globals the addon is loaded in
+ * and content globals the addon injects scripts into). The |makeDebugger|
+ * function helps us avoid repeating the logic for finding and maintaining the
+ * correct set of globals for a given |Debugger| instance across each version of
+ * all of our actors.
+ *
+ * The |makeDebugger| function expects a single object parameter with the
+ * following properties:
+ *
+ * @param Function findDebuggees
+ * Called with one argument: a |Debugger| instance. This function should
+ * return an iterable of globals to be added to the |Debugger|
+ * instance. The globals are the actual global objects and aren't wrapped
+ * in in a |Debugger.Object|.
+ *
+ * @param Function shouldAddNewGlobalAsDebuggee
+ * Called with one argument: a |Debugger.Object| wrapping a global
+ * object. This function must return |true| if the global object should
+ * be added as debuggee, and |false| otherwise.
+ *
+ * @returns Debugger
+ * Returns a |Debugger| instance that can manage its set of debuggee
+ * globals itself and is decorated with the |EventEmitter| class.
+ *
+ * Existing |Debugger| properties set on the returned |Debugger|
+ * instance:
+ *
+ * - onNewGlobalObject: The |Debugger| will automatically add new
+ * globals as debuggees if calling |shouldAddNewGlobalAsDebuggee|
+ * with the global returns true.
+ *
+ * - uncaughtExceptionHook: The |Debugger| already has an error
+ * reporter attached to |uncaughtExceptionHook|, so if any
+ * |Debugger| hooks fail, the error will be reported.
+ *
+ * New properties set on the returned |Debugger| instance:
+ *
+ * - addDebuggees: A function which takes no arguments. It adds all
+ * current globals that should be debuggees (as determined by
+ * |findDebuggees|) to the |Debugger| instance.
+ */
+module.exports = function makeDebugger({
+ findDebuggees,
+ shouldAddNewGlobalAsDebuggee,
+} = {}) {
+ const dbg = new Debugger();
+ EventEmitter.decorate(dbg);
+
+ // By default, we disable asm.js and WASM debugging because of performance reason.
+ // Enabling asm.js debugging (allowUnobservedAsmJS=false) will make asm.js fallback to JS compiler
+ // and be debugging as a regular JS script.
+ dbg.allowUnobservedAsmJS = true;
+ // Enabling WASM debugging (allowUnobservedWasm=false) will make the engine compile WASM scripts
+ // into different machine code with debugging instructions. This significantly increase the memory usage of it.
+ dbg.allowUnobservedWasm = true;
+
+ dbg.uncaughtExceptionHook = reportDebuggerHookException;
+
+ const onNewGlobalObject = function (global) {
+ if (shouldAddNewGlobalAsDebuggee(global)) {
+ safeAddDebuggee(this, global);
+ }
+ };
+
+ dbg.onNewGlobalObject = onNewGlobalObject;
+ dbg.addDebuggees = function () {
+ for (const global of findDebuggees(this)) {
+ safeAddDebuggee(this, global);
+ }
+ };
+
+ dbg.disable = function () {
+ dbg.removeAllDebuggees();
+ dbg.onNewGlobalObject = undefined;
+ };
+
+ dbg.enable = function () {
+ dbg.addDebuggees();
+ dbg.onNewGlobalObject = onNewGlobalObject;
+ };
+ dbg.findDebuggees = function () {
+ return findDebuggees(dbg);
+ };
+
+ return dbg;
+};
+
+const reportDebuggerHookException = e => reportException("DBG-SERVER", e);
+
+/**
+ * Add |global| as a debuggee to |dbg|, handling error cases.
+ */
+function safeAddDebuggee(dbg, global) {
+ let globalDO;
+ try {
+ globalDO = dbg.addDebuggee(global);
+ } catch (e) {
+ // Ignoring attempt to add the debugger's compartment as a debuggee.
+ return;
+ }
+
+ if (dbg.onNewDebuggee) {
+ dbg.onNewDebuggee(globalDO);
+ }
+}
diff --git a/devtools/server/actors/utils/moz.build b/devtools/server/actors/utils/moz.build
new file mode 100644
index 0000000000..405b25cb4b
--- /dev/null
+++ b/devtools/server/actors/utils/moz.build
@@ -0,0 +1,32 @@
+# -*- 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(
+ "accessibility.js",
+ "actor-registry.js",
+ "breakpoint-actor-map.js",
+ "capture-screenshot.js",
+ "css-grid-utils.js",
+ "custom-formatters.js",
+ "dbg-source.js",
+ "event-breakpoints.js",
+ "event-loop.js",
+ "gecko-profile-collector.js",
+ "inactive-property-helper.js",
+ "logEvent.js",
+ "make-debugger.js",
+ "shapes-utils.js",
+ "source-map-utils.js",
+ "source-url.js",
+ "sources-manager.js",
+ "stack.js",
+ "style-utils.js",
+ "stylesheet-utils.js",
+ "stylesheets-manager.js",
+ "track-change-emitter.js",
+ "walker-search.js",
+ "watchpoint-map.js",
+)
diff --git a/devtools/server/actors/utils/shapes-utils.js b/devtools/server/actors/utils/shapes-utils.js
new file mode 100644
index 0000000000..aab50bf952
--- /dev/null
+++ b/devtools/server/actors/utils/shapes-utils.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";
+
+/**
+ * Get the distance between two points on a plane.
+ * @param {Number} x1 the x coord of the first point
+ * @param {Number} y1 the y coord of the first point
+ * @param {Number} x2 the x coord of the second point
+ * @param {Number} y2 the y coord of the second point
+ * @returns {Number} the distance between the two points
+ */
+const getDistance = (x1, y1, x2, y2) => {
+ return Math.round(Math.hypot(x2 - x1, y2 - y1));
+};
+
+/**
+ * Determine if the given x/y coords are along the edge of the given ellipse.
+ * We allow for a small area around the edge that still counts as being on the edge.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} cx the x coordinate of the center of the ellipse
+ * @param {Number} cy the y coordinate of the center of the ellipse
+ * @param {Number} rx the x radius of the ellipse
+ * @param {Number} ry the y radius of the ellipse
+ * @param {Number} clickWidthX the width of the area that counts as being on the edge
+ * along the x radius.
+ * @param {Number} clickWidthY the width of the area that counts as being on the edge
+ * along the y radius.
+ * @returns {Boolean} whether the click counts as being on the edge of the ellipse.
+ */
+const clickedOnEllipseEdge = (
+ x,
+ y,
+ cx,
+ cy,
+ rx,
+ ry,
+ clickWidthX,
+ clickWidthY
+) => {
+ // The formula to determine if something is inside or on the edge of an ellipse is:
+ // (x - cx)^2/rx^2 + (y - cy)^2/ry^2 <= 1. If > 1, it's outside.
+ // We make two ellipses, adjusting rx and ry with clickWidthX and clickWidthY
+ // to allow for an area around the edge of the ellipse that can be clicked on.
+ // If the click was outside the inner ellipse and inside the outer ellipse, return true.
+ const inner =
+ (x - cx) ** 2 / (rx - clickWidthX) ** 2 +
+ (y - cy) ** 2 / (ry - clickWidthY) ** 2;
+ const outer =
+ (x - cx) ** 2 / (rx + clickWidthX) ** 2 +
+ (y - cy) ** 2 / (ry + clickWidthY) ** 2;
+ return inner >= 1 && outer <= 1;
+};
+
+/**
+ * Get the distance between a point and a line defined by two other points.
+ * @param {Number} x1 the x coordinate of the first point in the line
+ * @param {Number} y1 the y coordinate of the first point in the line
+ * @param {Number} x2 the x coordinate of the second point in the line
+ * @param {Number} y2 the y coordinate of the second point in the line
+ * @param {Number} x3 the x coordinate of the point for which the distance is found
+ * @param {Number} y3 the y coordinate of the point for which the distance is found
+ * @returns {Number} the distance between (x3,y3) and the line defined by
+ * (x1,y1) and (y1,y2)
+ */
+const distanceToLine = (x1, y1, x2, y2, x3, y3) => {
+ // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
+ const num = Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1);
+ const denom = getDistance(x1, y1, x2, y2);
+ return num / denom;
+};
+
+/**
+ * Get the point on the line defined by points a,b that is closest to point c
+ * @param {Number} ax the x coordinate of point a
+ * @param {Number} ay the y coordinate of point a
+ * @param {Number} bx the x coordinate of point b
+ * @param {Number} by the y coordinate of point b
+ * @param {Number} cx the x coordinate of point c
+ * @param {Number} cy the y coordinate of point c
+ * @returns {Array} a 2 element array that contains the x/y coords of the projected point
+ */
+const projection = (ax, ay, bx, by, cx, cy) => {
+ // https://en.wikipedia.org/wiki/Vector_projection#Vector_projection_2
+ const ab = [bx - ax, by - ay];
+ const ac = [cx - ax, cy - ay];
+ const scalar = dotProduct(ab, ac) / dotProduct(ab, ab);
+ return [ax + scalar * ab[0], ay + scalar * ab[1]];
+};
+
+/**
+ * Get the dot product of two vectors, represented by arrays of numbers.
+ * @param {Array} a the first vector
+ * @param {Array} b the second vector
+ * @returns {Number} the dot product of a and b
+ */
+const dotProduct = (a, b) => {
+ return a.reduce((prev, curr, i) => {
+ return prev + curr * b[i];
+ }, 0);
+};
+
+/**
+ * Determine if the given x/y coords are above the given point.
+ * @param {Number} x the x coordinate of the click
+ * @param {Number} y the y coordinate of the click
+ * @param {Number} pointX the x coordinate of the center of the point
+ * @param {Number} pointY the y coordinate of the center of the point
+ * @param {Number} radiusX the x radius of the point
+ * @param {Number} radiusY the y radius of the point
+ * @returns {Boolean} whether the click was on the point
+ */
+const clickedOnPoint = (x, y, pointX, pointY, radiusX, radiusY) => {
+ return (
+ x >= pointX - radiusX &&
+ x <= pointX + radiusX &&
+ y >= pointY - radiusY &&
+ y <= pointY + radiusY
+ );
+};
+
+const roundTo = (value, exp) => {
+ // If the exp is undefined or zero...
+ if (typeof exp === "undefined" || +exp === 0) {
+ return Math.round(value);
+ }
+ value = +value;
+ exp = +exp;
+ // If the value is not a number or the exp is not an integer...
+ if (isNaN(value) || !(typeof exp === "number" && exp % 1 === 0)) {
+ return NaN;
+ }
+ // Shift
+ value = value.toString().split("e");
+ value = Math.round(+(value[0] + "e" + (value[1] ? +value[1] - exp : -exp)));
+ // Shift back
+ value = value.toString().split("e");
+ return +(value[0] + "e" + (value[1] ? +value[1] + exp : exp));
+};
+
+exports.getDistance = getDistance;
+exports.clickedOnEllipseEdge = clickedOnEllipseEdge;
+exports.distanceToLine = distanceToLine;
+exports.projection = projection;
+exports.clickedOnPoint = clickedOnPoint;
+exports.roundTo = roundTo;
diff --git a/devtools/server/actors/utils/source-map-utils.js b/devtools/server/actors/utils/source-map-utils.js
new file mode 100644
index 0000000000..fccd0d67bf
--- /dev/null
+++ b/devtools/server/actors/utils/source-map-utils.js
@@ -0,0 +1,42 @@
+/* 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";
+
+exports.getSourcemapBaseURL = getSourcemapBaseURL;
+function getSourcemapBaseURL(url, global) {
+ let sourceMapBaseURL = null;
+ if (url) {
+ // Sources that have explicit URLs can be used directly as the base.
+ sourceMapBaseURL = url;
+ } else if (global?.location?.href) {
+ // If there is no URL for the source, the map comment is relative to the
+ // page being viewed, so we use the document href.
+ sourceMapBaseURL = global?.location?.href;
+ } else {
+ // If there is no valid base, the sourcemap URL will need to be an absolute
+ // URL of some kind.
+ return null;
+ }
+
+ // A data URL is large and will never be a valid base, so we can just treat
+ // it as if there is no base at all to avoid a sending it to the client
+ // for no reason.
+ if (sourceMapBaseURL.startsWith("data:")) {
+ return null;
+ }
+
+ // If the base URL is a blob, we want to resolve relative to the origin
+ // that created the blob URL, if there is one.
+ if (sourceMapBaseURL.startsWith("blob:")) {
+ try {
+ const parsedBaseURL = new URL(sourceMapBaseURL);
+ return parsedBaseURL.origin === "null" ? null : parsedBaseURL.origin;
+ } catch (err) {
+ return null;
+ }
+ }
+
+ return sourceMapBaseURL;
+}
diff --git a/devtools/server/actors/utils/source-url.js b/devtools/server/actors/utils/source-url.js
new file mode 100644
index 0000000000..be80025e46
--- /dev/null
+++ b/devtools/server/actors/utils/source-url.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";
+
+/**
+ * Debugger.Source objects have a `url` property that exposes the value
+ * that was passed to SpiderMonkey, but unfortunately often SpiderMonkey
+ * sets a URL even in cases where it doesn't make sense, so we have to
+ * explicitly ignore the URL value in these contexts to keep things a bit
+ * more consistent.
+ *
+ * @param {Debugger.Source} source
+ *
+ * @return {string | null}
+ */
+function getDebuggerSourceURL(source) {
+ const introType = source.introductionType;
+
+ // These are all the sources that are eval or eval-like, but may still have
+ // a URL set on the source, so we explicitly ignore the source URL for these.
+ if (
+ introType === "injectedScript" ||
+ introType === "eval" ||
+ introType === "debugger eval" ||
+ introType === "Function" ||
+ introType === "javascriptURL" ||
+ introType === "eventHandler" ||
+ introType === "domTimer"
+ ) {
+ return null;
+ }
+ // When using <iframe srcdoc="<script> js source </script>"/>, we can't easily fetch the srcdoc
+ // full html text content. So, consider each inline script as independant source with
+ // their own URL. Thus the ID appended to each URL.
+ if (source.url == "about:srcdoc") {
+ return source.url + "#" + source.id;
+ }
+
+ return source.url;
+}
+
+exports.getDebuggerSourceURL = getDebuggerSourceURL;
diff --git a/devtools/server/actors/utils/sources-manager.js b/devtools/server/actors/utils/sources-manager.js
new file mode 100644
index 0000000000..b80da69bfa
--- /dev/null
+++ b/devtools/server/actors/utils/sources-manager.js
@@ -0,0 +1,515 @@
+/* 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 DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const { assert, fetch } = DevToolsUtils;
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const {
+ SourceLocation,
+} = require("resource://devtools/server/actors/common.js");
+
+loader.lazyRequireGetter(
+ this,
+ "SourceActor",
+ "resource://devtools/server/actors/source.js",
+ true
+);
+
+/**
+ * Matches strings of the form "foo.min.js" or "foo-min.js", etc. If the regular
+ * expression matches, we can be fairly sure that the source is minified, and
+ * treat it as such.
+ */
+const MINIFIED_SOURCE_REGEXP = /\bmin\.js$/;
+
+/**
+ * Manages the sources for a thread. Handles URL contents, locations in
+ * the sources, etc for ThreadActors.
+ */
+class SourcesManager extends EventEmitter {
+ constructor(threadActor) {
+ super();
+ this._thread = threadActor;
+
+ this.blackBoxedSources = new Map();
+
+ // Debugger.Source -> SourceActor
+ this._sourceActors = new Map();
+
+ // URL -> content
+ //
+ // Any possibly incomplete content that has been loaded for each URL.
+ this._urlContents = new Map();
+
+ // URL -> Promise[]
+ //
+ // Any promises waiting on a URL to be completely loaded.
+ this._urlWaiters = new Map();
+
+ // Debugger.Source.id -> Debugger.Source
+ //
+ // The IDs associated with ScriptSources and available via DebuggerSource.id
+ // are internal to this process and should not be exposed to the client. This
+ // map associates these IDs with the corresponding source, provided the source
+ // has not been GC'ed and the actor has been created. This is lazily populated
+ // the first time it is needed.
+ this._sourcesByInternalSourceId = null;
+
+ if (!isWorker) {
+ Services.obs.addObserver(this, "devtools-html-content");
+ }
+ }
+
+ destroy() {
+ if (!isWorker) {
+ Services.obs.removeObserver(this, "devtools-html-content");
+ }
+ }
+
+ /**
+ * Clear existing sources so they are recreated on the next access.
+ */
+ reset() {
+ this._sourceActors = new Map();
+ this._urlContents = new Map();
+ this._urlWaiters = new Map();
+ this._sourcesByInternalSourceId = null;
+ }
+
+ /**
+ * Create a source actor representing this source.
+ *
+ * @param Debugger.Source source
+ * The source to make an actor for.
+ * @returns a SourceActor representing the source.
+ */
+ createSourceActor(source) {
+ assert(source, "SourcesManager.prototype.source needs a source");
+
+ if (this._sourceActors.has(source)) {
+ return this._sourceActors.get(source);
+ }
+
+ const actor = new SourceActor({
+ thread: this._thread,
+ source,
+ });
+
+ this._thread.threadLifetimePool.manage(actor);
+
+ this._sourceActors.set(source, actor);
+ if (this._sourcesByInternalSourceId && source.id) {
+ this._sourcesByInternalSourceId.set(source.id, source);
+ }
+
+ this.emit("newSource", actor);
+ return actor;
+ }
+
+ _getSourceActor(source) {
+ if (this._sourceActors.has(source)) {
+ return this._sourceActors.get(source);
+ }
+
+ return null;
+ }
+
+ hasSourceActor(source) {
+ return !!this._getSourceActor(source);
+ }
+
+ getSourceActor(source) {
+ const sourceActor = this._getSourceActor(source);
+
+ if (!sourceActor) {
+ throw new Error(
+ "getSource: could not find source actor for " + (source.url || "source")
+ );
+ }
+
+ return sourceActor;
+ }
+
+ getOrCreateSourceActor(source) {
+ // Tolerate the source coming from a different Debugger than the one
+ // associated with the thread.
+ try {
+ source = this._thread.dbg.adoptSource(source);
+ } catch (e) {
+ // We can't create actors for sources in the same compartment as the
+ // thread's Debugger.
+ if (/is in the same compartment as this debugger/.test(e)) {
+ return null;
+ }
+ throw e;
+ }
+
+ if (this.hasSourceActor(source)) {
+ return this.getSourceActor(source);
+ }
+ return this.createSourceActor(source);
+ }
+
+ getSourceActorByInternalSourceId(id) {
+ if (!this._sourcesByInternalSourceId) {
+ this._sourcesByInternalSourceId = new Map();
+ for (const source of this._thread.dbg.findSources()) {
+ if (source.id) {
+ this._sourcesByInternalSourceId.set(source.id, source);
+ }
+ }
+ }
+ const source = this._sourcesByInternalSourceId.get(id);
+ if (source) {
+ return this.getOrCreateSourceActor(source);
+ }
+ return null;
+ }
+
+ getSourceActorsByURL(url) {
+ const rv = [];
+ if (url) {
+ for (const [, actor] of this._sourceActors) {
+ if (actor.url === url) {
+ rv.push(actor);
+ }
+ }
+ }
+ return rv;
+ }
+
+ getSourceActorById(actorId) {
+ for (const [, actor] of this._sourceActors) {
+ if (actor.actorID == actorId) {
+ return actor;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the URL likely points to a minified resource, false
+ * otherwise.
+ *
+ * @param String uri
+ * The url to test.
+ * @returns Boolean
+ */
+ _isMinifiedURL(uri) {
+ if (!uri) {
+ return false;
+ }
+
+ try {
+ const url = new URL(uri);
+ const pathname = url.pathname;
+ return MINIFIED_SOURCE_REGEXP.test(
+ pathname.slice(pathname.lastIndexOf("/") + 1)
+ );
+ } catch (e) {
+ // Not a valid URL so don't try to parse out the filename, just test the
+ // whole thing with the minified source regexp.
+ return MINIFIED_SOURCE_REGEXP.test(uri);
+ }
+ }
+
+ /**
+ * Return the non-source-mapped location of an offset in a script.
+ *
+ * @param Debugger.Script script
+ * The script associated with the offset.
+ * @param Number offset
+ * Offset within the script of the location.
+ * @returns Object
+ * Returns an object of the form { source, line, column }
+ */
+ getScriptOffsetLocation(script, offset) {
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(offset);
+ // NOTE: Debugger.Source.prototype.startColumn is 1-based.
+ // Convert to 0-based, while keeping the wasm's column (1) as is.
+ // (bug 1863878)
+ const columnBase = script.format === "wasm" ? 0 : 1;
+ return new SourceLocation(
+ this.createSourceActor(script.source),
+ lineNumber,
+ columnNumber - columnBase
+ );
+ }
+
+ /**
+ * Return the non-source-mapped location of the given Debugger.Frame. If the
+ * frame does not have a script, the location's properties are all null.
+ *
+ * @param Debugger.Frame frame
+ * The frame whose location we are getting.
+ * @returns Object
+ * Returns an object of the form { source, line, column }
+ */
+ getFrameLocation(frame) {
+ if (!frame || !frame.script) {
+ return new SourceLocation();
+ }
+ return this.getScriptOffsetLocation(frame.script, frame.offset);
+ }
+
+ /**
+ * Returns true if URL for the given source is black boxed.
+ *
+ * * @param url String
+ * The URL of the source which we are checking whether it is black
+ * boxed or not.
+ */
+ isBlackBoxed(url, line, column) {
+ if (!this.blackBoxedSources.has(url)) {
+ return false;
+ }
+
+ const ranges = this.blackBoxedSources.get(url);
+
+ // If we have an entry in the map, but it is falsy, the source is fully blackboxed.
+ if (!ranges) {
+ return true;
+ }
+
+ const range = ranges.find(r => isLocationInRange({ line, column }, r));
+ return !!range;
+ }
+
+ isFrameBlackBoxed(frame) {
+ const { url, line, column } = this.getFrameLocation(frame);
+ return this.isBlackBoxed(url, line, column);
+ }
+
+ clearAllBlackBoxing() {
+ this.blackBoxedSources.clear();
+ }
+
+ /**
+ * Add the given source URL to the set of sources that are black boxed.
+ *
+ * @param url String
+ * The URL of the source which we are black boxing.
+ */
+ blackBox(url, range) {
+ if (!range) {
+ // blackbox the whole source
+ return this.blackBoxedSources.set(url, null);
+ }
+
+ const ranges = this.blackBoxedSources.get(url) || [];
+ // ranges are sorted in ascening order
+ const index = ranges.findIndex(
+ r => r.end.line <= range.start.line && r.end.column <= range.start.column
+ );
+
+ ranges.splice(index + 1, 0, range);
+ this.blackBoxedSources.set(url, ranges);
+ return true;
+ }
+
+ /**
+ * Remove the given source URL to the set of sources that are black boxed.
+ *
+ * @param url String
+ * The URL of the source which we are no longer black boxing.
+ */
+ unblackBox(url, range) {
+ if (!range) {
+ return this.blackBoxedSources.delete(url);
+ }
+
+ const ranges = this.blackBoxedSources.get(url);
+ const index = ranges.findIndex(
+ r =>
+ r.start.line === range.start.line &&
+ r.start.column === range.start.column &&
+ r.end.line === range.end.line &&
+ r.end.column === range.end.column
+ );
+
+ if (index !== -1) {
+ ranges.splice(index, 1);
+ }
+
+ if (ranges.length === 0) {
+ return this.blackBoxedSources.delete(url);
+ }
+
+ return this.blackBoxedSources.set(url, ranges);
+ }
+
+ iter() {
+ return [...this._sourceActors.values()];
+ }
+
+ /**
+ * Listener for new HTML content.
+ */
+ observe(subject, topic, data) {
+ if (topic == "devtools-html-content") {
+ const { parserID, uri, contents, complete } = JSON.parse(data);
+ if (this._urlContents.has(uri)) {
+ // We received many devtools-html-content events, if we already received one,
+ // aggregate the data with the one we already received.
+ const existing = this._urlContents.get(uri);
+ if (existing.parserID == parserID) {
+ assert(!existing.complete);
+ existing.content = existing.content + contents;
+ existing.complete = complete;
+
+ // After the HTML has finished loading, resolve any promises
+ // waiting for the complete file contents. Waits will only
+ // occur when the URL was ever partially loaded.
+ if (complete) {
+ const waiters = this._urlWaiters.get(uri);
+ if (waiters) {
+ for (const waiter of waiters) {
+ waiter();
+ }
+ this._urlWaiters.delete(uri);
+ }
+ }
+ }
+ } else if (contents) {
+ // Ensure that `contents` is non-empty. We may miss all the devtools-html-content events except the last
+ // one which has a empty `contents` and complete set to true.
+ // This reproduces when opening a same-process iframe. In this particular scenario, we instantiate the target and thread actor
+ // on `DOMDocElementInserted` and the HTML document is already parsed, but we still receive this one very last notification.
+ this._urlContents.set(uri, {
+ content: contents,
+ complete,
+ contentType: "text/html",
+ parserID,
+ });
+ }
+ }
+ }
+
+ /**
+ * Get the contents of a URL, fetching it if necessary. If partial is set and
+ * any content for the URL has been received, that partial content is returned
+ * synchronously.
+ */
+ urlContents(url, partial, canUseCache) {
+ if (this._urlContents.has(url)) {
+ const data = this._urlContents.get(url);
+ if (!partial && !data.complete) {
+ return new Promise(resolve => {
+ if (!this._urlWaiters.has(url)) {
+ this._urlWaiters.set(url, []);
+ }
+ this._urlWaiters.get(url).push(resolve);
+ }).then(() => {
+ assert(data.complete);
+ return {
+ content: data.content,
+ contentType: data.contentType,
+ };
+ });
+ }
+ return {
+ content: data.content,
+ contentType: data.contentType,
+ };
+ }
+ if (partial) {
+ return {
+ content: "",
+ contentType: "",
+ };
+ }
+ return this._fetchURLContents(url, partial, canUseCache);
+ }
+
+ async _fetchURLContents(url, partial, canUseCache) {
+ // Only try the cache if it is currently enabled for the document.
+ // Without this check, the cache may return stale data that doesn't match
+ // the document shown in the browser.
+ let loadFromCache = canUseCache;
+ if (canUseCache && this._thread._parent.browsingContext) {
+ loadFromCache = !(
+ this._thread._parent.browsingContext.defaultLoadFlags ===
+ Ci.nsIRequest.LOAD_BYPASS_CACHE
+ );
+ }
+
+ // Fetch the sources with the same principal as the original document
+ const win = this._thread._parent.window;
+ let principal, cacheKey;
+ // On xpcshell, we don't have a window but a Sandbox
+ if (!isWorker && win instanceof Ci.nsIDOMWindow) {
+ const docShell = win.docShell;
+ const channel = docShell.currentDocumentChannel;
+ principal = channel.loadInfo.loadingPrincipal;
+
+ // Retrieve the cacheKey in order to load POST requests from cache
+ // Note that chrome:// URLs don't support this interface.
+ if (
+ loadFromCache &&
+ docShell.currentDocumentChannel instanceof Ci.nsICacheInfoChannel
+ ) {
+ cacheKey = docShell.currentDocumentChannel.cacheKey;
+ }
+ }
+
+ let result;
+ try {
+ result = await fetch(url, {
+ principal,
+ cacheKey,
+ loadFromCache,
+ });
+ } catch (error) {
+ this._reportLoadSourceError(error);
+ throw error;
+ }
+
+ // When we fetch the contents, there is a risk that the contents we get
+ // do not match up with the actual text of the sources these contents will
+ // be associated with. We want to always show contents that include that
+ // actual text (otherwise it will be very confusing or unusable for users),
+ // so replace the contents with the actual text if there is a mismatch.
+ const actors = [...this._sourceActors.values()].filter(
+ actor => actor.url == url
+ );
+ if (!actors.every(actor => actor.contentMatches(result))) {
+ if (actors.length > 1) {
+ // When there are multiple actors we won't be able to show the source
+ // for all of them. Ask the user to reload so that we don't have to do
+ // any fetching.
+ result.content = "Error: Incorrect contents fetched, please reload.";
+ } else {
+ result.content = actors[0].actualText();
+ }
+ }
+
+ this._urlContents.set(url, { ...result, complete: true });
+
+ return result;
+ }
+
+ _reportLoadSourceError(error) {
+ try {
+ DevToolsUtils.reportException("SourceActor", error);
+
+ const lines = JSON.stringify(this.form(), null, 4).split(/\n/g);
+ lines.forEach(line => console.error("\t", line));
+ } catch (e) {
+ // ignore
+ }
+ }
+}
+
+function isLocationInRange({ line, column }, range) {
+ return (
+ (range.start.line <= line ||
+ (range.start.line == line && range.start.column <= column)) &&
+ (range.end.line >= line ||
+ (range.end.line == line && range.end.column >= column))
+ );
+}
+
+exports.SourcesManager = SourcesManager;
diff --git a/devtools/server/actors/utils/stack.js b/devtools/server/actors/utils/stack.js
new file mode 100644
index 0000000000..6a216b252c
--- /dev/null
+++ b/devtools/server/actors/utils/stack.js
@@ -0,0 +1,183 @@
+/* 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";
+
+/**
+ * A helper class that stores stack frame objects. Each frame is
+ * assigned an index, and if a frame is added more than once, the same
+ * index is used. Users of the class can get an array of all frames
+ * that have been added.
+ */
+class StackFrameCache {
+ /**
+ * Initialize this object.
+ */
+ constructor() {
+ this._framesToIndices = null;
+ this._framesToForms = null;
+ this._lastEventSize = 0;
+ }
+
+ /**
+ * Prepare to accept frames.
+ */
+ initFrames() {
+ if (this._framesToIndices) {
+ // The maps are already initialized.
+ return;
+ }
+
+ this._framesToIndices = new Map();
+ this._framesToForms = new Map();
+ this._lastEventSize = 0;
+ }
+
+ /**
+ * Forget all stored frames and reset to the initialized state.
+ */
+ clearFrames() {
+ this._framesToIndices.clear();
+ this._framesToIndices = null;
+ this._framesToForms.clear();
+ this._framesToForms = null;
+ this._lastEventSize = 0;
+ }
+
+ /**
+ * Add a frame to this stack frame cache, and return the index of
+ * the frame.
+ */
+ addFrame(frame) {
+ this._assignFrameIndices(frame);
+ this._createFrameForms(frame);
+ return this._framesToIndices.get(frame);
+ }
+
+ /**
+ * A helper method for the memory actor. This populates the packet
+ * object with "frames" property. Each of these
+ * properties will be an array indexed by frame ID. "frames" will
+ * contain frame objects (see makeEvent).
+ *
+ * @param packet
+ * The packet to update.
+ *
+ * @returns packet
+ */
+ updateFramePacket(packet) {
+ // Now that we are guaranteed to have a form for every frame, we know the
+ // size the "frames" property's array must be. We use that information to
+ // create dense arrays even though we populate them out of order.
+ const size = this._framesToForms.size;
+ packet.frames = Array(size).fill(null);
+
+ // Populate the "frames" properties.
+ for (const [stack, index] of this._framesToIndices) {
+ packet.frames[index] = this._framesToForms.get(stack);
+ }
+
+ return packet;
+ }
+
+ /**
+ * If any new stack frames have been added to this cache since the
+ * last call to makeEvent (clearing the cache also resets the "last
+ * call"), then return a new array describing the new frames. If no
+ * new frames are available, return null.
+ *
+ * The frame cache assumes that the user of the cache keeps track of
+ * all previously-returned arrays and, in theory, concatenates them
+ * all to form a single array holding all frames added to the cache
+ * since the last reset. This concatenated array can be indexed by
+ * the frame ID. The array returned by this function, though, is
+ * dense and starts at 0.
+ *
+ * Each element in the array is an object of the form:
+ * {
+ * line: <line number for this frame>,
+ * column: <column number for this frame>,
+ * source: <filename string for this frame>,
+ * functionDisplayName: <this frame's inferred function name function or null>,
+ * parent: <frame ID -- an index into the concatenated array mentioned above>
+ * asyncCause: the async cause, or null
+ * asyncParent: <frame ID -- an index into the concatenated array mentioned above>
+ * }
+ *
+ * The intent of this approach is to make it simpler to efficiently
+ * send frame information over the debugging protocol, by only
+ * sending new frames.
+ *
+ * @returns array or null
+ */
+ makeEvent() {
+ const size = this._framesToForms.size;
+ if (!size || size <= this._lastEventSize) {
+ return null;
+ }
+
+ const packet = Array(size - this._lastEventSize).fill(null);
+ for (const [stack, index] of this._framesToIndices) {
+ if (index >= this._lastEventSize) {
+ packet[index - this._lastEventSize] = this._framesToForms.get(stack);
+ }
+ }
+
+ this._lastEventSize = size;
+
+ return packet;
+ }
+
+ /**
+ * Assigns an index to the given frame and its parents, if an index is not
+ * already assigned.
+ *
+ * @param SavedFrame frame
+ * A frame to assign an index to.
+ */
+ _assignFrameIndices(frame) {
+ if (this._framesToIndices.has(frame)) {
+ return;
+ }
+
+ if (frame) {
+ this._assignFrameIndices(frame.parent);
+ this._assignFrameIndices(frame.asyncParent);
+ }
+
+ const index = this._framesToIndices.size;
+ this._framesToIndices.set(frame, index);
+ }
+
+ /**
+ * Create the form for the given frame, if one doesn't already exist.
+ *
+ * @param SavedFrame frame
+ * A frame to create a form for.
+ */
+ _createFrameForms(frame) {
+ if (this._framesToForms.has(frame)) {
+ return;
+ }
+
+ let form = null;
+ if (frame) {
+ form = {
+ line: frame.line,
+ column: frame.column,
+ source: frame.source,
+ functionDisplayName: frame.functionDisplayName,
+ parent: this._framesToIndices.get(frame.parent),
+ asyncParent: this._framesToIndices.get(frame.asyncParent),
+ asyncCause: frame.asyncCause,
+ };
+ this._createFrameForms(frame.parent);
+ this._createFrameForms(frame.asyncParent);
+ }
+
+ this._framesToForms.set(frame, form);
+ }
+}
+
+exports.StackFrameCache = StackFrameCache;
diff --git a/devtools/server/actors/utils/style-utils.js b/devtools/server/actors/utils/style-utils.js
new file mode 100644
index 0000000000..5f2e912002
--- /dev/null
+++ b/devtools/server/actors/utils/style-utils.js
@@ -0,0 +1,211 @@
+/* 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 { getCSSLexer } = require("resource://devtools/shared/css/lexer.js");
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const FONT_PREVIEW_TEXT = "Abc";
+const FONT_PREVIEW_FONT_SIZE = 40;
+const FONT_PREVIEW_FILLSTYLE = "black";
+// Offset (in px) to avoid cutting off text edges of italic fonts.
+const FONT_PREVIEW_OFFSET = 4;
+// Factor used to resize the canvas in order to get better text quality.
+const FONT_PREVIEW_OVERSAMPLING_FACTOR = 2;
+
+/**
+ * Helper function for getting an image preview of the given font.
+ *
+ * @param font {string}
+ * Name of font to preview
+ * @param doc {Document}
+ * Document to use to render font
+ * @param options {object}
+ * Object with options 'previewText' and 'previewFontSize'
+ *
+ * @return dataUrl
+ * The data URI of the font preview image
+ */
+function getFontPreviewData(font, doc, options) {
+ options = options || {};
+ const previewText = options.previewText || FONT_PREVIEW_TEXT;
+ const previewTextLines = previewText.split("\n");
+ const previewFontSize = options.previewFontSize || FONT_PREVIEW_FONT_SIZE;
+ const fillStyle = options.fillStyle || FONT_PREVIEW_FILLSTYLE;
+ const fontStyle = options.fontStyle || "";
+
+ const canvas = doc.createElementNS(XHTML_NS, "canvas");
+ const ctx = canvas.getContext("2d");
+ const fontValue =
+ fontStyle + " " + previewFontSize + "px " + font + ", serif";
+
+ // Get the correct preview text measurements and set the canvas dimensions
+ ctx.font = fontValue;
+ ctx.fillStyle = fillStyle;
+ const previewTextLinesWidths = previewTextLines.map(
+ previewTextLine => ctx.measureText(previewTextLine).width
+ );
+ const textWidth = Math.round(Math.max(...previewTextLinesWidths));
+
+ // The canvas width is calculated as the width of the longest line plus
+ // an offset at the left and right of it.
+ // The canvas height is calculated as the font size multiplied by the
+ // number of lines plus an offset at the top and bottom.
+ //
+ // In order to get better text quality, we oversample the canvas.
+ // That means, after the width and height are calculated, we increase
+ // both sizes by some factor.
+ const simpleCanvasWidth = textWidth + FONT_PREVIEW_OFFSET * 2;
+ canvas.width = simpleCanvasWidth * FONT_PREVIEW_OVERSAMPLING_FACTOR;
+ canvas.height =
+ (previewFontSize * previewTextLines.length + FONT_PREVIEW_OFFSET * 2) *
+ FONT_PREVIEW_OVERSAMPLING_FACTOR;
+
+ // we have to reset these after changing the canvas size
+ ctx.font = fontValue;
+ ctx.fillStyle = fillStyle;
+
+ // Oversample the canvas for better text quality
+ ctx.scale(FONT_PREVIEW_OVERSAMPLING_FACTOR, FONT_PREVIEW_OVERSAMPLING_FACTOR);
+
+ ctx.textBaseline = "top";
+ ctx.textAlign = "center";
+ const horizontalTextPosition = simpleCanvasWidth / 2;
+ let verticalTextPosition = FONT_PREVIEW_OFFSET;
+ for (let i = 0; i < previewTextLines.length; i++) {
+ ctx.fillText(
+ previewTextLines[i],
+ horizontalTextPosition,
+ verticalTextPosition
+ );
+
+ // Move vertical text position one line down
+ verticalTextPosition += previewFontSize;
+ }
+
+ const dataURL = canvas.toDataURL("image/png");
+
+ return {
+ dataURL,
+ size: textWidth + FONT_PREVIEW_OFFSET * 2,
+ };
+}
+
+exports.getFontPreviewData = getFontPreviewData;
+
+/**
+ * Get the text content of a rule given some CSS text, a line and a column
+ * Consider the following example:
+ * body {
+ * color: red;
+ * }
+ * p {
+ * line-height: 2em;
+ * color: blue;
+ * }
+ * Calling the function with the whole text above and line=4 and column=1 would
+ * return "line-height: 2em; color: blue;"
+ * @param {String} initialText
+ * @param {Number} line (1-indexed)
+ * @param {Number} column (1-indexed)
+ * @return {object} An object of the form {offset: number, text: string}
+ * The offset is the index into the input string where
+ * the rule text started. The text is the content of
+ * the rule.
+ */
+function getRuleText(initialText, line, column) {
+ if (typeof line === "undefined" || typeof column === "undefined") {
+ throw new Error("Location information is missing");
+ }
+
+ const { offset: textOffset, text } = getTextAtLineColumn(
+ initialText,
+ line,
+ column
+ );
+ const lexer = getCSSLexer(text);
+
+ // Search forward for the opening brace.
+ while (true) {
+ const token = lexer.nextToken();
+ if (!token) {
+ throw new Error("couldn't find start of the rule");
+ }
+ if (token.tokenType === "symbol" && token.text === "{") {
+ break;
+ }
+ }
+
+ // Now collect text until we see the matching close brace.
+ let braceDepth = 1;
+ let startOffset, endOffset;
+ while (true) {
+ const token = lexer.nextToken();
+ if (!token) {
+ break;
+ }
+ if (startOffset === undefined) {
+ startOffset = token.startOffset;
+ }
+ if (token.tokenType === "symbol") {
+ if (token.text === "{") {
+ ++braceDepth;
+ } else if (token.text === "}") {
+ --braceDepth;
+ if (braceDepth == 0) {
+ break;
+ }
+ }
+ }
+ endOffset = token.endOffset;
+ }
+
+ // If the rule was of the form "selector {" with no closing brace
+ // and no properties, just return an empty string.
+ if (startOffset === undefined) {
+ return { offset: 0, text: "" };
+ }
+ // If the input didn't have any tokens between the braces (e.g.,
+ // "div {}"), then the endOffset won't have been set yet; so account
+ // for that here.
+ if (endOffset === undefined) {
+ endOffset = startOffset;
+ }
+
+ // Note that this approach will preserve comments, despite the fact
+ // that cssTokenizer skips them.
+ return {
+ offset: textOffset + startOffset,
+ text: text.substring(startOffset, endOffset),
+ };
+}
+
+exports.getRuleText = getRuleText;
+
+/**
+ * Return the offset and substring of |text| that starts at the given
+ * line and column.
+ * @param {String} text
+ * @param {Number} line (1-indexed)
+ * @param {Number} column (1-indexed)
+ * @return {object} An object of the form {offset: number, text: string},
+ * where the offset is the offset into the input string
+ * where the text starts, and where text is the text.
+ */
+function getTextAtLineColumn(text, line, column) {
+ let offset;
+ if (line > 1) {
+ const rx = new RegExp(
+ "(?:[^\\r\\n\\f]*(?:\\r\\n|\\n|\\r|\\f)){" + (line - 1) + "}"
+ );
+ offset = rx.exec(text)[0].length;
+ } else {
+ offset = 0;
+ }
+ offset += column - 1;
+ return { offset, text: text.substr(offset) };
+}
+
+exports.getTextAtLineColumn = getTextAtLineColumn;
diff --git a/devtools/server/actors/utils/stylesheet-utils.js b/devtools/server/actors/utils/stylesheet-utils.js
new file mode 100644
index 0000000000..682a752c3d
--- /dev/null
+++ b/devtools/server/actors/utils/stylesheet-utils.js
@@ -0,0 +1,155 @@
+/* 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 { fetch } = require("resource://devtools/shared/DevToolsUtils.js");
+
+/**
+ * For imported stylesheets, `ownerNode` is null.
+ *
+ * To resolve the ownerNode for an imported stylesheet, loop on `parentStylesheet`
+ * until we reach the topmost stylesheet, which should have a valid ownerNode.
+ *
+ * Constructable stylesheets do not have an owner node and this method will
+ * return null.
+ *
+ * @param {StyleSheet}
+ * The stylesheet for which we want to retrieve the ownerNode.
+ * @return {DOMNode|null} The ownerNode or null for constructable stylesheets.
+ */
+function getStyleSheetOwnerNode(sheet) {
+ // If this is not an imported stylesheet and we have an ownerNode available
+ // bail out immediately.
+ if (sheet.ownerNode) {
+ return sheet.ownerNode;
+ }
+
+ let parentStyleSheet = sheet;
+ while (
+ parentStyleSheet.parentStyleSheet &&
+ parentStyleSheet !== parentStyleSheet.parentStyleSheet
+ ) {
+ parentStyleSheet = parentStyleSheet.parentStyleSheet;
+ }
+
+ return parentStyleSheet.ownerNode;
+}
+
+exports.getStyleSheetOwnerNode = getStyleSheetOwnerNode;
+
+/**
+ * Get the text of a stylesheet.
+ *
+ * TODO: A call site in window-global.js expects this method to return a promise
+ * so it is mandatory to keep it as an async function even if we are not using
+ * await explicitly. Bug 1810572.
+ *
+ * @param {StyleSheet}
+ * The stylesheet for which we want to retrieve the text.
+ * @returns {Promise}
+ */
+async function getStyleSheetText(styleSheet) {
+ if (!styleSheet.href) {
+ if (styleSheet.ownerNode) {
+ // this is an inline <style> sheet
+ return styleSheet.ownerNode.textContent;
+ }
+ // Constructed stylesheet.
+ // TODO(bug 1769933, bug 1809108): Maybe preserve authored text?
+ return "";
+ }
+
+ return fetchStyleSheetText(styleSheet);
+}
+
+exports.getStyleSheetText = getStyleSheetText;
+
+/**
+ * Retrieve the content of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+async function fetchStyleSheetText(styleSheet) {
+ const href = styleSheet.href;
+
+ const options = {
+ loadFromCache: true,
+ policy: Ci.nsIContentPolicy.TYPE_INTERNAL_STYLESHEET,
+ charset: getCSSCharset(styleSheet),
+ headers: {
+ // https://searchfox.org/mozilla-central/rev/68b1b0041a78abd06f19202558ccc4922e5ba759/netwerk/protocol/http/nsHttpHandler.cpp#124
+ accept: "text/css,*/*;q=0.1",
+ },
+ };
+
+ // Bug 1282660 - We use the system principal to load the default internal
+ // stylesheets instead of the content principal since such stylesheets
+ // require system principal to load. At meanwhile, we strip the loadGroup
+ // for preventing the assertion of the userContextId mismatching.
+
+ // chrome|file|resource|moz-extension protocols rely on the system principal.
+ const excludedProtocolsRe = /^(chrome|file|resource|moz-extension):\/\//;
+ if (!excludedProtocolsRe.test(href)) {
+ // Stylesheets using other protocols should use the content principal.
+ const ownerNode = getStyleSheetOwnerNode(styleSheet);
+ if (ownerNode) {
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ options.window = ownerNode.ownerDocument.defaultView;
+ options.principal = ownerNode.ownerDocument.nodePrincipal;
+ }
+ }
+
+ let result;
+
+ try {
+ result = await fetch(href, options);
+ if (result.contentType !== "text/css") {
+ console.warn(
+ `stylesheets: fetch from cache returned non-css content-type ` +
+ `${result.contentType} for ${href}, trying without cache.`
+ );
+ options.loadFromCache = false;
+ result = await fetch(href, options);
+ }
+ } catch (e) {
+ // The list of excluded protocols can be missing some protocols, try to use the
+ // system principal if the first fetch failed.
+ console.error(
+ `stylesheets: fetch failed for ${href},` +
+ ` using system principal instead.`
+ );
+ options.window = undefined;
+ options.principal = undefined;
+ result = await fetch(href, options);
+ }
+
+ return result.content;
+}
+
+/**
+ * Get charset of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+function getCSSCharset(styleSheet) {
+ if (styleSheet) {
+ // charset attribute of <link> or <style> element, if it exists
+ if (styleSheet.ownerNode?.getAttribute) {
+ const linkCharset = styleSheet.ownerNode.getAttribute("charset");
+ if (linkCharset != null) {
+ return linkCharset;
+ }
+ }
+
+ // charset of referring document.
+ if (styleSheet.ownerNode?.ownerDocument.characterSet) {
+ return styleSheet.ownerNode.ownerDocument.characterSet;
+ }
+ }
+
+ return "UTF-8";
+}
diff --git a/devtools/server/actors/utils/stylesheets-manager.js b/devtools/server/actors/utils/stylesheets-manager.js
new file mode 100644
index 0000000000..838e5be602
--- /dev/null
+++ b/devtools/server/actors/utils/stylesheets-manager.js
@@ -0,0 +1,1031 @@
+/* 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 {
+ getSourcemapBaseURL,
+} = require("resource://devtools/server/actors/utils/source-map-utils.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["addPseudoClassLock", "removePseudoClassLock"],
+ "resource://devtools/server/actors/highlighters/utils/markup.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "loadSheet",
+ "resource://devtools/shared/layout/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getStyleSheetOwnerNode", "getStyleSheetText"],
+ "resource://devtools/server/actors/utils/stylesheet-utils.js",
+ true
+);
+
+const TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning";
+const TRANSITION_DURATION_MS = 500;
+const TRANSITION_BUFFER_MS = 1000;
+const TRANSITION_RULE_SELECTOR = `:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *:not(:-moz-native-anonymous)`;
+const TRANSITION_SHEET =
+ "data:text/css;charset=utf-8," +
+ encodeURIComponent(`
+ ${TRANSITION_RULE_SELECTOR} {
+ transition-duration: ${TRANSITION_DURATION_MS}ms !important;
+ transition-delay: 0ms !important;
+ transition-timing-function: ease-out !important;
+ transition-property: all !important;
+ }
+`);
+
+// The possible kinds of style-applied events.
+// UPDATE_PRESERVING_RULES means that the update is guaranteed to
+// preserve the number and order of rules on the style sheet.
+// UPDATE_GENERAL covers any other kind of change to the style sheet.
+const UPDATE_PRESERVING_RULES = 0;
+const UPDATE_GENERAL = 1;
+
+// If the user edits a stylesheet, we stash a copy of the edited text
+// here, keyed by the stylesheet. This way, if the tools are closed
+// and then reopened, the edited text will be available. A weak map
+// is used so that navigation by the user will eventually cause the
+// edited text to be collected.
+const modifiedStyleSheets = new WeakMap();
+
+/**
+ * Manage stylesheets related to a given Target Actor.
+ * @emits stylesheet-updated: emitted when there was changes in a stylesheet
+ * First arg is an object with the following properties:
+ * - resourceId {String}: The id that was assigned to the stylesheet
+ * - updateKind {String}: Which kind of update it is ("style-applied",
+ * "at-rules-changed", "matches-change", "property-change")
+ * - updates {Object}: The update data
+ */
+class StyleSheetsManager extends EventEmitter {
+ #abortController;
+ // Map<resourceId, AbortController>
+ #mqlChangeAbortControllerMap = new Map();
+ #styleSheetCount = 0;
+ #styleSheetMap = new Map();
+ #styleSheetCreationData;
+ #targetActor;
+ #transitionSheetLoaded;
+ #transitionTimeout;
+ #watchListeners = {
+ onAvailable: [],
+ onUpdated: [],
+ onDestroyed: [],
+ };
+
+ /**
+ * @param TargetActor targetActor
+ * The target actor from which we should observe stylesheet changes.
+ */
+ constructor(targetActor) {
+ super();
+
+ this.#targetActor = targetActor;
+ }
+
+ #setEventListenersIfNeeded() {
+ if (this.#abortController) {
+ return;
+ }
+
+ this.#abortController = new AbortController();
+ const { signal } = this.#abortController;
+
+ // Listen for new stylesheet being added via StyleSheetApplicableStateChanged
+ this.#targetActor.chromeEventHandler.addEventListener(
+ "StyleSheetApplicableStateChanged",
+ this.#onApplicableStateChanged,
+ { capture: true, signal }
+ );
+ this.#targetActor.chromeEventHandler.addEventListener(
+ "StyleSheetRemoved",
+ this.#onStylesheetRemoved,
+ { capture: true, signal }
+ );
+
+ this.#watchStyleSheetChangeEvents();
+ this.#targetActor.on("window-ready", this.#onTargetActorWindowReady, {
+ signal,
+ });
+ }
+
+ /**
+ * Calling this function will make the StyleSheetsManager start the event listeners needed
+ * to watch for stylesheet additions and modifications.
+ * This resolves once it notified about existing stylesheets.
+ * @param {Object} options
+ * @param {Function} onAvailable: Function that will be called when a stylesheet is
+ * registered, but also with already registered stylesheets
+ * if ignoreExisting is not set to true.
+ * This is called with a single object parameter with the following properties:
+ * - {String} resourceId: The id that was assigned to the stylesheet
+ * - {StyleSheet} styleSheet: The actual stylesheet object
+ * - {Object} creationData: An object with:
+ * - {Boolean} isCreatedByDevTools: Was the stylesheet created
+ * by DevTools (e.g. by the user clicking the new stylesheet
+ * button in the styleeditor)
+ * - {String} fileName
+ * @param {Function} onUpdated: Function that will be called when a stylesheet is updated
+ * This is called with a single object parameter with the following properties:
+ * - {String} resourceId: The id that was assigned to the stylesheet
+ * - {String} updateKind: Which kind of update it is ("style-applied",
+ * "at-rules-changed", "matches-change", "property-change")
+ * - {Object} updates : The update data
+ * @param {Function} onDestroyed: Function that will be called when a stylesheet is removed
+ * This is called with a single object parameter with the following properties:
+ * - {String} resourceId: The id that was assigned to the stylesheet
+ * @param {Boolean} ignoreExisting: Pass to true to avoid onAvailable to be called with
+ * already registered stylesheets.
+ */
+ async watch({ onAvailable, onUpdated, onDestroyed, ignoreExisting = false }) {
+ if (!onAvailable && !onUpdated && !onDestroyed) {
+ throw new Error("Expect onAvailable, onUpdated or onDestroyed");
+ }
+
+ if (onAvailable) {
+ if (typeof onAvailable !== "function") {
+ throw new Error("onAvailable should be a function");
+ }
+
+ // Don't register the listener yet if we're ignoring existing stylesheets, we'll do
+ // that at the end of the function, after we processed existing stylesheets.
+ }
+
+ if (onUpdated) {
+ if (typeof onUpdated !== "function") {
+ throw new Error("onUpdated should be a function");
+ }
+ this.#watchListeners.onUpdated.push(onUpdated);
+ }
+
+ if (onDestroyed) {
+ if (typeof onDestroyed !== "function") {
+ throw new Error("onDestroyed should be a function");
+ }
+ this.#watchListeners.onDestroyed.push(onDestroyed);
+ }
+
+ // Process existing stylesheets
+ const promises = [];
+ for (const window of this.#targetActor.windows) {
+ promises.push(this.#getStyleSheetsForWindow(window));
+ }
+
+ this.#setEventListenersIfNeeded();
+
+ // Finally, notify about existing stylesheets
+ const styleSheets = await Promise.all(promises);
+ const styleSheetsData = styleSheets.flat().map(styleSheet => ({
+ styleSheet,
+ resourceId: this.#registerStyleSheet(styleSheet),
+ }));
+
+ let registeredStyleSheetsPromises;
+ if (onAvailable && ignoreExisting !== true) {
+ registeredStyleSheetsPromises = styleSheetsData.map(
+ ({ resourceId, styleSheet }) => onAvailable({ resourceId, styleSheet })
+ );
+ }
+
+ // Only register the listener after we went over the list of existing stylesheets
+ // so the listener is not triggered by possible calls to #registerStyleSheet earlier.
+ if (onAvailable) {
+ this.#watchListeners.onAvailable.push(onAvailable);
+ }
+
+ if (registeredStyleSheetsPromises) {
+ await Promise.all(registeredStyleSheetsPromises);
+ }
+ }
+
+ /**
+ * Remove the passed listeners
+ *
+ * @param {Object} options: See this.watch
+ */
+ unwatch({ onAvailable, onUpdated, onDestroyed }) {
+ if (!this.#watchListeners) {
+ return;
+ }
+
+ if (onAvailable) {
+ const index = this.#watchListeners.onAvailable.indexOf(onAvailable);
+ if (index !== -1) {
+ this.#watchListeners.onAvailable.splice(index, 1);
+ }
+ }
+
+ if (onUpdated) {
+ const index = this.#watchListeners.onUpdated.indexOf(onUpdated);
+ if (index !== -1) {
+ this.#watchListeners.onUpdated.splice(index, 1);
+ }
+ }
+
+ if (onDestroyed) {
+ const index = this.#watchListeners.onDestroyed.indexOf(onDestroyed);
+ if (index !== -1) {
+ this.#watchListeners.onDestroyed.splice(index, 1);
+ }
+ }
+ }
+
+ #watchStyleSheetChangeEvents() {
+ for (const window of this.#targetActor.windows) {
+ this.#watchStyleSheetChangeEventsForWindow(window);
+ }
+ }
+
+ #onTargetActorWindowReady = ({ window }) => {
+ this.#watchStyleSheetChangeEventsForWindow(window);
+ };
+
+ #watchStyleSheetChangeEventsForWindow(window) {
+ // We have to set this flag in order to get the
+ // StyleSheetApplicableStateChanged and StyleSheetRemoved events. See Document.webidl.
+ window.document.styleSheetChangeEventsEnabled = true;
+ }
+
+ #unwatchStyleSheetChangeEvents() {
+ for (const window of this.#targetActor.windows) {
+ window.document.styleSheetChangeEventsEnabled = false;
+ }
+ }
+
+ /**
+ * Create a new style sheet in the document with the given text.
+ *
+ * @param {Document} document
+ * Document that the new style sheet belong to.
+ * @param {string} text
+ * Content of style sheet.
+ * @param {string} fileName
+ * If the stylesheet adding is from file, `fileName` indicates the path.
+ */
+ async addStyleSheet(document, text, fileName) {
+ const parent = document.documentElement;
+ const style = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "style"
+ );
+ style.setAttribute("type", "text/css");
+ style.setDevtoolsAsTriggeringPrincipal();
+
+ if (text) {
+ style.appendChild(document.createTextNode(text));
+ }
+
+ // This triggers StyleSheetApplicableStateChanged event.
+ parent.appendChild(style);
+
+ // This promise will be resolved when the resource for this stylesheet is available.
+ let resolve = null;
+ const promise = new Promise(r => {
+ resolve = r;
+ });
+
+ if (!this.#styleSheetCreationData) {
+ this.#styleSheetCreationData = new WeakMap();
+ }
+ this.#styleSheetCreationData.set(style.sheet, {
+ isCreatedByDevTools: true,
+ fileName,
+ resolve,
+ });
+
+ await promise;
+
+ return style.sheet;
+ }
+
+ /**
+ * Return resourceId of the given style sheet or create one if the stylesheet wasn't
+ * registered yet.
+ *
+ * @params {StyleSheet} styleSheet
+ * @returns {String} resourceId
+ */
+ getStyleSheetResourceId(styleSheet) {
+ const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
+ if (existingResourceId) {
+ return existingResourceId;
+ }
+
+ // If we couldn't find an associated resourceId, that means the stylesheet isn't
+ // registered yet. Calling #registerStyleSheet will register it and return the
+ // associated resourceId it computed for it.
+ return this.#registerStyleSheet(styleSheet);
+ }
+
+ /**
+ * Return the associated resourceId of the given registered style sheet, or null if the
+ * stylesheet wasn't registered yet.
+ *
+ * @params {StyleSheet} styleSheet
+ * @returns {String} resourceId
+ */
+ #findStyleSheetResourceId(styleSheet) {
+ for (const [
+ resourceId,
+ existingStyleSheet,
+ ] of this.#styleSheetMap.entries()) {
+ if (styleSheet === existingStyleSheet) {
+ return resourceId;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Return owner node of the style sheet of the given resource id.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {Element|null}
+ */
+ getOwnerNode(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ return styleSheet.ownerNode;
+ }
+
+ /**
+ * Return the index of given stylesheet of the given resource id.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {Number}
+ */
+ getStyleSheetIndex(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+
+ const styleSheets = InspectorUtils.getAllStyleSheets(
+ this.#targetActor.window.document,
+ true
+ );
+ let i = 0;
+ for (const sheet of styleSheets) {
+ if (!this.#shouldListSheet(sheet)) {
+ continue;
+ }
+ if (sheet == styleSheet) {
+ return i;
+ }
+ i++;
+ }
+ return -1;
+ }
+
+ /**
+ * Get the text of a stylesheet given its resourceId.
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @returns {String}
+ */
+ async getText(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+
+ const modifiedText = modifiedStyleSheets.get(styleSheet);
+
+ // modifiedText is the content of the stylesheet updated by update function.
+ // In case not updating, this is undefined.
+ if (modifiedText !== undefined) {
+ return modifiedText;
+ }
+
+ return getStyleSheetText(styleSheet);
+ }
+
+ /**
+ * Toggle the disabled property of the stylesheet
+ *
+ * @params {String} resourceId
+ * The id associated with the stylesheet
+ * @return {Boolean} the disabled state after toggling.
+ */
+ toggleDisabled(resourceId) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ styleSheet.disabled = !styleSheet.disabled;
+
+ this.#notifyPropertyChanged(resourceId, "disabled", styleSheet.disabled);
+
+ return styleSheet.disabled;
+ }
+
+ /**
+ * Update the style sheet in place with new text.
+ *
+ * @param {String} resourceId
+ * @param {String} text
+ * New text.
+ * @param {Object} options
+ * @param {Boolean} options.transition
+ * Whether to do CSS transition for change. Defaults to false.
+ * @param {Number} options.kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL. Defaults to UPDATE_GENERAL.
+ * @param {String} options.cause
+ * Indicates the cause of this update (e.g. "styleeditor") if this was called
+ * from the stylesheet to be edited by the user from the StyleEditor.
+ */
+ async setStyleSheetText(
+ resourceId,
+ text,
+ { transition = false, kind = UPDATE_GENERAL, cause = "" } = {}
+ ) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ InspectorUtils.parseStyleSheet(styleSheet, text);
+ modifiedStyleSheets.set(styleSheet, text);
+
+ const { atRules, ruleCount } =
+ this.getStyleSheetRuleCountAndAtRules(styleSheet);
+
+ if (kind !== UPDATE_PRESERVING_RULES) {
+ this.#notifyPropertyChanged(resourceId, "ruleCount", ruleCount);
+ }
+
+ if (transition) {
+ this.#startTransition(resourceId, kind, cause);
+ } else {
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "style-applied",
+ updates: {
+ event: { kind, cause },
+ },
+ });
+ }
+
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "at-rules-changed",
+ updates: {
+ resourceUpdates: { atRules },
+ },
+ });
+ }
+
+ /**
+ * Applies a transition to the stylesheet document so any change made by the user in the
+ * client will be animated so it's more visible.
+ *
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @param {Number} kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {String} cause
+ * Indicates the cause of this update (e.g. "styleeditor") if this was called
+ * from the stylesheet to be edited by the user from the StyleEditor.
+ */
+ #startTransition(resourceId, kind, cause) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ const document = styleSheet.associatedDocument;
+ const window = document.ownerGlobal;
+
+ if (!this.#transitionSheetLoaded) {
+ this.#transitionSheetLoaded = true;
+ // We don't remove this sheet. It uses an internal selector that
+ // we only apply via locks, so there's no need to load and unload
+ // it all the time.
+ loadSheet(window, TRANSITION_SHEET);
+ }
+
+ addPseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
+
+ // Set up clean up and commit after transition duration (+buffer)
+ // @see #onTransitionEnd
+ window.clearTimeout(this.#transitionTimeout);
+ this.#transitionTimeout = window.setTimeout(
+ this.#onTransitionEnd.bind(this, resourceId, kind, cause),
+ TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS
+ );
+ }
+
+ /**
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @param {Number} kind
+ * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL
+ * @param {String} cause
+ * Indicates the cause of this update (e.g. "styleeditor") if this was called
+ * from the stylesheet to be edited by the user from the StyleEditor.
+ */
+ #onTransitionEnd(resourceId, kind, cause) {
+ const styleSheet = this.#styleSheetMap.get(resourceId);
+ const document = styleSheet.associatedDocument;
+
+ this.#transitionTimeout = null;
+ removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS);
+
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "style-applied",
+ updates: {
+ event: { kind, cause },
+ },
+ });
+ }
+
+ /**
+ * Retrieve the CSSRuleList of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {CSSRuleList}
+ */
+ #getCSSRules(styleSheet) {
+ try {
+ return styleSheet.cssRules;
+ } catch (e) {
+ // sheet isn't loaded yet
+ }
+
+ if (!styleSheet.ownerNode) {
+ return Promise.resolve([]);
+ }
+
+ return new Promise(resolve => {
+ styleSheet.ownerNode.addEventListener(
+ "load",
+ () => resolve(styleSheet.cssRules),
+ { once: true }
+ );
+ });
+ }
+
+ /**
+ * Get the stylesheets imported by a given stylesheet (via @import)
+ *
+ * @param {Document} document
+ * @param {StyleSheet} styleSheet
+ * @returns Array<StyleSheet>
+ */
+ async #getImportedStyleSheets(document, styleSheet) {
+ const importedStyleSheets = [];
+
+ for (const rule of await this.#getCSSRules(styleSheet)) {
+ const ruleClassName = ChromeUtils.getClassName(rule);
+ if (ruleClassName == "CSSImportRule") {
+ // With the Gecko style system, the associated styleSheet may be null
+ // if it has already been seen because an import cycle for the same
+ // URL. With Stylo, the styleSheet will exist (which is correct per
+ // the latest CSSOM spec), so we also need to check ancestors for the
+ // same URL to avoid cycles.
+ if (
+ !rule.styleSheet ||
+ this.#haveAncestorWithSameURL(rule.styleSheet) ||
+ !this.#shouldListSheet(rule.styleSheet)
+ ) {
+ continue;
+ }
+
+ importedStyleSheets.push(rule.styleSheet);
+
+ // recurse imports in this stylesheet as well
+ const children = await this.#getImportedStyleSheets(
+ document,
+ rule.styleSheet
+ );
+ importedStyleSheets.push(...children);
+ } else if (ruleClassName != "CSSCharsetRule") {
+ // @import rules must precede all others except @charset
+ break;
+ }
+ }
+
+ return importedStyleSheets;
+ }
+
+ /**
+ * Retrieve the total number of rules (including nested ones) and
+ * all the at-rules of a given stylesheet.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Object} An object of the following shape:
+ * - {Integer} ruleCount: The total number of rules in the stylesheet
+ * - {Array<Object>} atRules: An array of object of the following shape:
+ * - type {String}
+ * - conditionText {String}
+ * - matches {Boolean}: true if the media rule matches the current state of the document
+ * - layerName {String}
+ * - line {Number}
+ * - column {Number}
+ */
+ getStyleSheetRuleCountAndAtRules(styleSheet) {
+ const resourceId = this.#findStyleSheetResourceId(styleSheet);
+ if (!resourceId) {
+ return [];
+ }
+
+ if (this.#mqlChangeAbortControllerMap.has(resourceId)) {
+ this.#mqlChangeAbortControllerMap.get(resourceId).abort();
+ this.#mqlChangeAbortControllerMap.delete(resourceId);
+ }
+
+ // Accessing the stylesheet associated window might be slow due to cross compartment
+ // wrappers, so only retrieve it if it's needed.
+ let win;
+ const getStyleSheetAssociatedWindow = () => {
+ if (!win) {
+ win = styleSheet.associatedDocument?.ownerGlobal;
+ }
+ return win;
+ };
+
+ const styleSheetRules =
+ InspectorUtils.getAllStyleSheetCSSStyleRules(styleSheet);
+ const ruleCount = styleSheetRules.length;
+ // We need to go through nested rules to extract all the rules we're interested in
+ const atRules = [];
+ for (const rule of styleSheetRules) {
+ const className = ChromeUtils.getClassName(rule);
+ if (className === "CSSMediaRule") {
+ let matches = false;
+
+ try {
+ const associatedWin = getStyleSheetAssociatedWindow();
+ const mql = associatedWin.matchMedia(rule.media.mediaText);
+ matches = mql.matches;
+
+ let ac = this.#mqlChangeAbortControllerMap.get(resourceId);
+ if (!ac) {
+ ac = new associatedWin.AbortController();
+ this.#mqlChangeAbortControllerMap.set(resourceId, ac);
+ }
+
+ const index = atRules.length;
+ mql.addEventListener(
+ "change",
+ () => this.#onMatchesChange(resourceId, index, mql),
+ {
+ signal: ac.signal,
+ }
+ );
+ } catch (e) {
+ // Ignored
+ }
+
+ atRules.push({
+ type: "media",
+ conditionText: rule.conditionText,
+ matches,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ } else if (className === "CSSContainerRule") {
+ atRules.push({
+ type: "container",
+ conditionText: rule.conditionText,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ } else if (className === "CSSSupportsRule") {
+ atRules.push({
+ type: "support",
+ conditionText: rule.conditionText,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ } else if (className === "CSSLayerBlockRule") {
+ atRules.push({
+ type: "layer",
+ layerName: rule.name,
+ line: InspectorUtils.getRelativeRuleLine(rule),
+ column: InspectorUtils.getRuleColumn(rule),
+ });
+ }
+ }
+ return { ruleCount, atRules };
+ }
+
+ /**
+ * Called when the status of a media query support changes (i.e. it now matches, or it
+ * was matching but isn't anymore)
+ *
+ * @param {String} resourceId
+ * The id associated with the stylesheet
+ * @param {Number} index
+ * The index of the media rule relatively to all the other at-rules of the stylesheet
+ * @param {MediaQueryList} mql
+ * The result of matchMedia for the given media rule
+ */
+ #onMatchesChange(resourceId, index, mql) {
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "matches-change",
+ updates: {
+ nestedResourceUpdates: [
+ {
+ path: ["atRules", index, "matches"],
+ value: mql.matches,
+ },
+ ],
+ },
+ });
+ }
+
+ /**
+ * Get the node href of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ getNodeHref(styleSheet) {
+ const { ownerNode } = styleSheet;
+ if (!ownerNode) {
+ return null;
+ }
+
+ if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) {
+ return ownerNode.location.href;
+ }
+
+ if (ownerNode.ownerDocument?.location) {
+ return ownerNode.ownerDocument.location.href;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the sourcemap base url of a given stylesheet
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String}
+ */
+ getSourcemapBaseURL(styleSheet) {
+ // When the style is injected via nsIDOMWindowUtils.loadSheet, even
+ // the parent style sheet has no owner, so default back to target actor
+ // document
+ const ownerNode = getStyleSheetOwnerNode(styleSheet);
+ const ownerDocument = ownerNode
+ ? ownerNode.ownerDocument
+ : this.#targetActor.window;
+
+ return getSourcemapBaseURL(
+ // Technically resolveSourceURL should be used here alongside
+ // "this.rawSheet.sourceURL", but the style inspector does not support
+ // /*# sourceURL=*/ in CSS, so we're omitting it here (bug 880831).
+ styleSheet.href || this.getNodeHref(styleSheet),
+ ownerDocument
+ );
+ }
+
+ /**
+ * Get all the stylesheets for a given window
+ *
+ * @param {Window} window
+ * @returns {Array<StyleSheet>}
+ */
+ async #getStyleSheetsForWindow(window) {
+ const { document } = window;
+ const documentOnly = !document.nodePrincipal.isSystemPrincipal;
+
+ const styleSheets = [];
+
+ for (const styleSheet of InspectorUtils.getAllStyleSheets(
+ document,
+ documentOnly
+ )) {
+ if (!this.#shouldListSheet(styleSheet)) {
+ continue;
+ }
+
+ styleSheets.push(styleSheet);
+
+ // Get all sheets, including imported ones
+ const importedStyleSheets = await this.#getImportedStyleSheets(
+ document,
+ styleSheet
+ );
+ styleSheets.push(...importedStyleSheets);
+ }
+
+ return styleSheets;
+ }
+
+ /**
+ * Returns true if a given stylesheet has an ancestor with the same url it has
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Boolean}
+ */
+ #haveAncestorWithSameURL(styleSheet) {
+ const href = styleSheet.href;
+ while (styleSheet.parentStyleSheet) {
+ if (styleSheet.parentStyleSheet.href == href) {
+ return true;
+ }
+ styleSheet = styleSheet.parentStyleSheet;
+ }
+ return false;
+ }
+
+ /**
+ * Helper function called when a property changed in a given stylesheet
+ *
+ * @param {String} resourceId
+ * The id of the stylesheet the change occured in
+ * @param {String} property
+ * The property that was changed
+ * @param {String} value
+ * The value of the property
+ */
+ #notifyPropertyChanged(resourceId, property, value) {
+ this.#onStyleSheetUpdated({
+ resourceId,
+ updateKind: "property-change",
+ updates: { resourceUpdates: { [property]: value } },
+ });
+ }
+
+ /**
+ * Event handler that is called when the state of applicable of style sheet is changed.
+ *
+ * For now, StyleSheetApplicableStateChanged event will be called at following timings.
+ * - Append <link> of stylesheet to document
+ * - Append <style> to document
+ * - Change disable attribute of stylesheet object
+ * - Change disable attribute of <link> to false
+ * - Stylesheet is constructed.
+ * When appending <link>, <style> or changing `disabled` attribute to false,
+ * `applicable` is passed as true. The other hand, when changing `disabled`
+ * to true, this will be false.
+ *
+ * NOTE: StyleSheetApplicableStateChanged is _not_ called when removing the <link>/<style>,
+ * but a StyleSheetRemovedEvent is emitted in such case (see #onStyleSheetRemoved)
+ *
+ * @param {StyleSheetApplicableStateChangedEvent}
+ * The triggering event.
+ */
+ #onApplicableStateChanged = ({ applicable, stylesheet: styleSheet }) => {
+ if (
+ // Have interest in applicable stylesheet only.
+ applicable &&
+ styleSheet.associatedDocument &&
+ (!this.#targetActor.ignoreSubFrames ||
+ styleSheet.associatedDocument.ownerGlobal ===
+ this.#targetActor.window) &&
+ this.#shouldListSheet(styleSheet) &&
+ !this.#haveAncestorWithSameURL(styleSheet)
+ ) {
+ this.#registerStyleSheet(styleSheet);
+ }
+ };
+
+ /**
+ * Event handler that is called when a style sheet is removed.
+ *
+ * @param {StyleSheetRemovedEvent}
+ * The triggering event.
+ */
+ #onStylesheetRemoved = event => {
+ this.#unregisterStyleSheet(event.stylesheet);
+ };
+
+ /**
+ * If the stylesheet isn't registered yet, this function will generate an associated
+ * resourceId and call registered `onAvailable` listeners.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {String} the associated resourceId
+ */
+ #registerStyleSheet(styleSheet) {
+ const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
+ // If the stylesheet is already registered, there's no need to notify about it again.
+ if (existingResourceId) {
+ return existingResourceId;
+ }
+
+ // It's important to prefix the resourceId with the target actorID so we can't have
+ // duplicated resource ids when the client connects to multiple targets.
+ const resourceId = `${this.#targetActor.actorID}:stylesheet:${this
+ .#styleSheetCount++}`;
+ this.#styleSheetMap.set(resourceId, styleSheet);
+
+ const creationData = this.#styleSheetCreationData?.get(styleSheet);
+ this.#styleSheetCreationData?.delete(styleSheet);
+
+ const onAvailablePromises = [];
+ for (const onAvailable of this.#watchListeners.onAvailable) {
+ onAvailablePromises.push(
+ onAvailable({
+ resourceId,
+ styleSheet,
+ creationData,
+ })
+ );
+ }
+
+ // creationData exists if this stylesheet was created via `addStyleSheet`.
+ if (creationData) {
+ // We resolve the promise once the watcher sent the resources to the client,
+ // so `addStyleSheet` calls can be fullfilled.
+ Promise.all(onAvailablePromises).then(() => creationData?.resolve());
+ }
+ return resourceId;
+ }
+
+ /**
+ * If the stylesheet is registered, this function will call registered `onDestroyed`
+ * listeners with the stylesheet resourceId.
+ *
+ * @param {StyleSheet} styleSheet
+ */
+ #unregisterStyleSheet(styleSheet) {
+ const existingResourceId = this.#findStyleSheetResourceId(styleSheet);
+ if (!existingResourceId) {
+ return;
+ }
+
+ this.#styleSheetMap.delete(existingResourceId);
+ this.#styleSheetCreationData?.delete(styleSheet);
+ if (this.#mqlChangeAbortControllerMap.has(existingResourceId)) {
+ this.#mqlChangeAbortControllerMap.get(existingResourceId).abort();
+ this.#mqlChangeAbortControllerMap.delete(existingResourceId);
+ }
+
+ for (const onDestroyed of this.#watchListeners.onDestroyed) {
+ onDestroyed({
+ resourceId: existingResourceId,
+ });
+ }
+ }
+
+ #onStyleSheetUpdated(data) {
+ this.emit("stylesheet-updated", data);
+
+ for (const onUpdated of this.#watchListeners.onUpdated) {
+ onUpdated(data);
+ }
+ }
+
+ /**
+ * Returns true if the passed styleSheet should be handled.
+ *
+ * @param {StyleSheet} styleSheet
+ * @returns {Boolean}
+ */
+ #shouldListSheet(styleSheet) {
+ const href = styleSheet.href?.toLowerCase();
+ // FIXME(bug 1826538): Make accessiblecaret.css and similar UA-widget
+ // sheets system sheets, then remove this special-case.
+ if (
+ href === "resource://content-accessible/accessiblecaret.css" ||
+ (href === "resource://devtools-highlighter-styles/highlighters.css" &&
+ this.#targetActor.sessionContext.type !== "all")
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * The StyleSheetsManager instance is managed by the target, so this will be called when
+ * the target gets destroyed.
+ */
+ destroy() {
+ // Cleanup
+ if (this.#abortController) {
+ this.#abortController.abort();
+ }
+ if (this.#mqlChangeAbortControllerMap) {
+ for (const ac of this.#mqlChangeAbortControllerMap.values()) {
+ ac.abort();
+ }
+ }
+
+ try {
+ this.#unwatchStyleSheetChangeEvents();
+ } catch (e) {
+ console.error(
+ "Error when destroying StyleSheet manager for",
+ this.#targetActor,
+ ": ",
+ e
+ );
+ }
+
+ this.#styleSheetMap.clear();
+ this.#abortController = null;
+ this.#mqlChangeAbortControllerMap = null;
+ this.#styleSheetCreationData = null;
+ this.#styleSheetMap = null;
+ this.#targetActor = null;
+ this.#watchListeners = null;
+ }
+}
+
+module.exports = {
+ StyleSheetsManager,
+ UPDATE_GENERAL,
+ UPDATE_PRESERVING_RULES,
+};
diff --git a/devtools/server/actors/utils/track-change-emitter.js b/devtools/server/actors/utils/track-change-emitter.js
new file mode 100644
index 0000000000..19de2b92fb
--- /dev/null
+++ b/devtools/server/actors/utils/track-change-emitter.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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+/**
+ * A helper class that is listened to by the ChangesActor, and can be
+ * used to send changes to the ChangesActor.
+ */
+class TrackChangeEmitter extends EventEmitter {
+ trackChange(change) {
+ this.emit("track-change", change);
+ }
+}
+
+module.exports = new TrackChangeEmitter();
diff --git a/devtools/server/actors/utils/walker-search.js b/devtools/server/actors/utils/walker-search.js
new file mode 100644
index 0000000000..a5ffb48fad
--- /dev/null
+++ b/devtools/server/actors/utils/walker-search.js
@@ -0,0 +1,320 @@
+/* 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,
+ "isWhitespaceTextNode",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+/**
+ * The walker-search module provides a simple API to index and search strings
+ * and elements inside a given document.
+ * It indexes tag names, attribute names and values, and text contents.
+ * It provides a simple search function that returns a list of nodes that
+ * matched.
+ */
+
+class WalkerIndex {
+ /**
+ * The WalkerIndex class indexes the document (and all subdocs) from
+ * a given walker.
+ *
+ * It is only indexed the first time the data is accessed and will be
+ * re-indexed if a mutation happens between requests.
+ *
+ * @param {Walker} walker The walker to be indexed
+ */
+ constructor(walker) {
+ this.walker = walker;
+ this.clearIndex = this.clearIndex.bind(this);
+
+ // Kill the index when mutations occur, the next data get will re-index.
+ this.walker.on("any-mutation", this.clearIndex);
+ }
+
+ /**
+ * Destroy this instance, releasing all data and references
+ */
+ destroy() {
+ this.walker.off("any-mutation", this.clearIndex);
+ }
+
+ clearIndex() {
+ if (!this.currentlyIndexing) {
+ this._data = null;
+ }
+ }
+
+ get doc() {
+ return this.walker.rootDoc;
+ }
+
+ /**
+ * Get the indexed data
+ * This getter also indexes if it hasn't been done yet or if the state is
+ * dirty
+ *
+ * @returns Map<String, Array<{type:String, node:DOMNode}>>
+ * A Map keyed on the searchable value, containing an array with
+ * objects containing the 'type' (one of ALL_RESULTS_TYPES), and
+ * the DOM Node.
+ */
+ get data() {
+ if (!this._data) {
+ this._data = new Map();
+ this.index();
+ }
+
+ return this._data;
+ }
+
+ _addToIndex(type, node, value) {
+ // Add an entry for this value if there isn't one
+ const entry = this._data.get(value);
+ if (!entry) {
+ this._data.set(value, []);
+ }
+
+ // Add the type/node to the list
+ this._data.get(value).push({
+ type,
+ node,
+ });
+ }
+
+ index() {
+ // Handle case where iterating nextNode() with the deepTreeWalker triggers
+ // a mutation (Bug 1222558)
+ this.currentlyIndexing = true;
+
+ const documentWalker = this.walker.getDocumentWalker(this.doc);
+ while (documentWalker.nextNode()) {
+ const node = documentWalker.currentNode;
+
+ if (
+ this.walker.targetActor.ignoreSubFrames &&
+ node.ownerDocument !== this.doc
+ ) {
+ continue;
+ }
+
+ if (node.nodeType === 1) {
+ // For each element node, we get the tagname and all attributes names
+ // and values
+ const localName = node.localName;
+ if (localName === "_moz_generated_content_marker") {
+ this._addToIndex("tag", node, "::marker");
+ this._addToIndex("text", node, node.textContent.trim());
+ } else if (localName === "_moz_generated_content_before") {
+ this._addToIndex("tag", node, "::before");
+ this._addToIndex("text", node, node.textContent.trim());
+ } else if (localName === "_moz_generated_content_after") {
+ this._addToIndex("tag", node, "::after");
+ this._addToIndex("text", node, node.textContent.trim());
+ } else {
+ this._addToIndex("tag", node, node.localName);
+ }
+
+ for (const { name, value } of node.attributes) {
+ this._addToIndex("attributeName", node, name);
+ this._addToIndex("attributeValue", node, value);
+ }
+ } else if (node.textContent && node.textContent.trim().length) {
+ // For comments and text nodes, we get the text
+ this._addToIndex("text", node, node.textContent.trim());
+ }
+ }
+
+ this.currentlyIndexing = false;
+ }
+}
+
+exports.WalkerIndex = WalkerIndex;
+
+class WalkerSearch {
+ /**
+ * The WalkerSearch class provides a way to search an indexed document as well
+ * as find elements that match a given css selector.
+ *
+ * Usage example:
+ * let s = new WalkerSearch(doc);
+ * let res = s.search("lang", index);
+ * for (let {matched, results} of res) {
+ * for (let {node, type} of results) {
+ * console.log("The query matched a node's " + type);
+ * console.log("Node that matched", node);
+ * }
+ * }
+ * s.destroy();
+ *
+ * @param {Walker} the walker to be searched
+ */
+ constructor(walker) {
+ this.walker = walker;
+ this.index = new WalkerIndex(this.walker);
+ }
+
+ destroy() {
+ this.index.destroy();
+ this.walker = null;
+ }
+
+ _addResult(node, type, results) {
+ if (!results.has(node)) {
+ results.set(node, []);
+ }
+
+ const matches = results.get(node);
+
+ // Do not add if the exact same result is already in the list
+ let isKnown = false;
+ for (const match of matches) {
+ if (match.type === type) {
+ isKnown = true;
+ break;
+ }
+ }
+
+ if (!isKnown) {
+ matches.push({ type });
+ }
+ }
+
+ _searchIndex(query, options, results) {
+ for (const [matched, res] of this.index.data) {
+ if (!options.searchMethod(query, matched)) {
+ continue;
+ }
+
+ // Add any relevant results (skipping non-requested options).
+ res
+ .filter(entry => {
+ return options.types.includes(entry.type);
+ })
+ .forEach(({ node, type }) => {
+ this._addResult(node, type, results);
+ });
+ }
+ }
+
+ _searchSelectors(query, options, results) {
+ // If the query is just one "word", no need to search because _searchIndex
+ // will lead the same results since it has access to tagnames anyway
+ const isSelector = query && query.match(/[ >~.#\[\]]/);
+ if (!options.types.includes("selector") || !isSelector) {
+ return;
+ }
+
+ const nodes = this.walker._multiFrameQuerySelectorAll(query);
+ for (const node of nodes) {
+ this._addResult(node, "selector", results);
+ }
+ }
+
+ _searchXPath(query, options, results) {
+ if (!options.types.includes("xpath")) {
+ return;
+ }
+
+ const nodes = this.walker._multiFrameXPath(query);
+ for (const node of nodes) {
+ // Exclude text nodes that only contain whitespace
+ // because they are not displayed in the Inspector.
+ if (!isWhitespaceTextNode(node)) {
+ this._addResult(node, "xpath", results);
+ }
+ }
+ }
+
+ /**
+ * Search the document
+ * @param {String} query What to search for
+ * @param {Object} options The following options are accepted:
+ * - searchMethod {String} one of WalkerSearch.SEARCH_METHOD_*
+ * defaults to WalkerSearch.SEARCH_METHOD_CONTAINS (does not apply to
+ * selector and XPath search types)
+ * - types {Array} a list of things to search for (tag, text, attributes, etc)
+ * defaults to WalkerSearch.ALL_RESULTS_TYPES
+ * @return {Array} An array is returned with each item being an object like:
+ * {
+ * node: <the dom node that matched>,
+ * type: <the type of match: one of WalkerSearch.ALL_RESULTS_TYPES>
+ * }
+ */
+ search(query, options = {}) {
+ options.searchMethod =
+ options.searchMethod || WalkerSearch.SEARCH_METHOD_CONTAINS;
+ options.types = options.types || WalkerSearch.ALL_RESULTS_TYPES;
+
+ // Empty strings will return no results, as will non-string input
+ if (typeof query !== "string") {
+ query = "";
+ }
+
+ // Store results in a map indexed by nodes to avoid duplicate results
+ const results = new Map();
+
+ // Search through the indexed data
+ this._searchIndex(query, options, results);
+
+ // Search with querySelectorAll
+ this._searchSelectors(query, options, results);
+
+ // Search with XPath
+ this._searchXPath(query, options, results);
+
+ // Concatenate all results into an Array to return
+ const resultList = [];
+ for (const [node, matches] of results) {
+ for (const { type } of matches) {
+ resultList.push({
+ node,
+ type,
+ });
+
+ // For now, just do one result per node since the frontend
+ // doesn't have a way to highlight each result individually
+ // yet.
+ break;
+ }
+ }
+
+ const documents = this.walker.targetActor.windows.map(win => win.document);
+
+ // Sort the resulting nodes by order of appearance in the DOM
+ resultList.sort((a, b) => {
+ // Disconnected nodes won't get good results from compareDocumentPosition
+ // so check the order of their document instead.
+ if (a.node.ownerDocument != b.node.ownerDocument) {
+ const indA = documents.indexOf(a.node.ownerDocument);
+ const indB = documents.indexOf(b.node.ownerDocument);
+ return indA - indB;
+ }
+ // If the same document, then sort on DOCUMENT_POSITION_FOLLOWING (4)
+ // which means B is after A.
+ return a.node.compareDocumentPosition(b.node) & 4 ? -1 : 1;
+ });
+
+ return resultList;
+ }
+}
+
+WalkerSearch.SEARCH_METHOD_CONTAINS = (query, candidate) => {
+ return query && candidate.toLowerCase().includes(query.toLowerCase());
+};
+
+WalkerSearch.ALL_RESULTS_TYPES = [
+ "tag",
+ "text",
+ "attributeName",
+ "attributeValue",
+ "selector",
+ "xpath",
+];
+
+exports.WalkerSearch = WalkerSearch;
diff --git a/devtools/server/actors/utils/watchpoint-map.js b/devtools/server/actors/utils/watchpoint-map.js
new file mode 100644
index 0000000000..7e4e3ee54c
--- /dev/null
+++ b/devtools/server/actors/utils/watchpoint-map.js
@@ -0,0 +1,163 @@
+/* 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 WatchpointMap {
+ constructor(threadActor) {
+ this.threadActor = threadActor;
+ this._watchpoints = new Map();
+ }
+
+ _setWatchpoint(objActor, data) {
+ const { property, label, watchpointType } = data;
+ const obj = objActor.rawValue();
+
+ const desc = objActor.obj.getOwnPropertyDescriptor(property);
+
+ if (this.has(obj, property) || desc.set || desc.get || !desc.configurable) {
+ return null;
+ }
+
+ function getValue() {
+ return typeof desc.value === "object" && desc.value
+ ? desc.value.unsafeDereference()
+ : desc.value;
+ }
+
+ function setValue(v) {
+ desc.value = objActor.obj.makeDebuggeeValue(v);
+ }
+
+ const maybeHandlePause = type => {
+ const frame = this.threadActor.dbg.getNewestFrame();
+
+ if (
+ this.threadActor.shouldSkipAnyBreakpoint ||
+ !this.threadActor.hasMoved(frame, type) ||
+ this.threadActor.sourcesManager.isFrameBlackBoxed(frame)
+ ) {
+ return;
+ }
+
+ this.threadActor._pauseAndRespond(frame, {
+ type,
+ message: label,
+ });
+ };
+
+ if (watchpointType === "get") {
+ objActor.obj.defineProperty(property, {
+ configurable: desc.configurable,
+ enumerable: desc.enumerable,
+ set: objActor.obj.makeDebuggeeValue(v => {
+ setValue(v);
+ }),
+ get: objActor.obj.makeDebuggeeValue(() => {
+ maybeHandlePause("getWatchpoint");
+ return getValue();
+ }),
+ });
+ }
+
+ if (watchpointType === "set") {
+ objActor.obj.defineProperty(property, {
+ configurable: desc.configurable,
+ enumerable: desc.enumerable,
+ set: objActor.obj.makeDebuggeeValue(v => {
+ maybeHandlePause("setWatchpoint");
+ setValue(v);
+ }),
+ get: objActor.obj.makeDebuggeeValue(() => {
+ return getValue();
+ }),
+ });
+ }
+
+ if (watchpointType === "getorset") {
+ objActor.obj.defineProperty(property, {
+ configurable: desc.configurable,
+ enumerable: desc.enumerable,
+ set: objActor.obj.makeDebuggeeValue(v => {
+ maybeHandlePause("setWatchpoint");
+ setValue(v);
+ }),
+ get: objActor.obj.makeDebuggeeValue(() => {
+ maybeHandlePause("getWatchpoint");
+ return getValue();
+ }),
+ });
+ }
+
+ return desc;
+ }
+
+ add(objActor, data) {
+ // Get the object's description before calling setWatchpoint,
+ // otherwise we'll get the modified property descriptor instead
+ const desc = this._setWatchpoint(objActor, data);
+ if (!desc) {
+ return;
+ }
+
+ const objWatchpoints =
+ this._watchpoints.get(objActor.rawValue()) || new Map();
+
+ objWatchpoints.set(data.property, { ...data, desc });
+ this._watchpoints.set(objActor.rawValue(), objWatchpoints);
+ }
+
+ has(obj, property) {
+ const objWatchpoints = this._watchpoints.get(obj);
+ return objWatchpoints && objWatchpoints.has(property);
+ }
+
+ get(obj, property) {
+ const objWatchpoints = this._watchpoints.get(obj);
+ return objWatchpoints && objWatchpoints.get(property);
+ }
+
+ remove(objActor, property) {
+ const obj = objActor.rawValue();
+
+ // This should remove watchpoints on all of the object's properties if
+ // a property isn't passed in as an argument
+ if (!property) {
+ for (const objProperty in obj) {
+ this.remove(objActor, objProperty);
+ }
+ }
+
+ if (!this.has(obj, property)) {
+ return;
+ }
+
+ const objWatchpoints = this._watchpoints.get(obj);
+ const { desc } = objWatchpoints.get(property);
+
+ objWatchpoints.delete(property);
+ this._watchpoints.set(obj, objWatchpoints);
+
+ // We should stop keeping track of an object if it no longer
+ // has a watchpoint
+ if (objWatchpoints.size == 0) {
+ this._watchpoints.delete(obj);
+ }
+
+ objActor.obj.defineProperty(property, desc);
+ }
+
+ removeAll(objActor) {
+ const objWatchpoints = this._watchpoints.get(objActor.rawValue());
+ if (!objWatchpoints) {
+ return;
+ }
+
+ for (const objProperty in objWatchpoints) {
+ this.remove(objActor, objProperty);
+ }
+ }
+}
+
+exports.WatchpointMap = WatchpointMap;
diff --git a/devtools/server/actors/watcher.js b/devtools/server/actors/watcher.js
new file mode 100644
index 0000000000..97d2be01e4
--- /dev/null
+++ b/devtools/server/actors/watcher.js
@@ -0,0 +1,864 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const { watcherSpec } = require("resource://devtools/shared/specs/watcher.js");
+
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+const { TargetActorRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs",
+ {
+ loadInDevToolsLoader: false,
+ }
+);
+const { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+const { getAllBrowsingContextsForContext } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs"
+);
+const {
+ SESSION_TYPES,
+} = require("resource://devtools/server/actors/watcher/session-context.js");
+
+const TARGET_HELPERS = {};
+loader.lazyRequireGetter(
+ TARGET_HELPERS,
+ Targets.TYPES.FRAME,
+ "resource://devtools/server/actors/watcher/target-helpers/frame-helper.js"
+);
+loader.lazyRequireGetter(
+ TARGET_HELPERS,
+ Targets.TYPES.PROCESS,
+ "resource://devtools/server/actors/watcher/target-helpers/process-helper.js"
+);
+loader.lazyRequireGetter(
+ TARGET_HELPERS,
+ Targets.TYPES.SERVICE_WORKER,
+ "devtools/server/actors/watcher/target-helpers/service-worker-helper"
+);
+loader.lazyRequireGetter(
+ TARGET_HELPERS,
+ Targets.TYPES.WORKER,
+ "resource://devtools/server/actors/watcher/target-helpers/worker-helper.js"
+);
+
+loader.lazyRequireGetter(
+ this,
+ "NetworkParentActor",
+ "resource://devtools/server/actors/network-monitor/network-parent.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "BlackboxingActor",
+ "resource://devtools/server/actors/blackboxing.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "BreakpointListActor",
+ "resource://devtools/server/actors/breakpoint-list.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "TargetConfigurationActor",
+ "resource://devtools/server/actors/target-configuration.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ThreadConfigurationActor",
+ "resource://devtools/server/actors/thread-configuration.js",
+ true
+);
+
+exports.WatcherActor = class WatcherActor extends Actor {
+ /**
+ * Initialize a new WatcherActor which is the main entry point to debug
+ * something. The main features of this actor are to:
+ * - observe targets related to the context we are debugging.
+ * This is done via watchTargets/unwatchTargets methods, and
+ * target-available-form/target-destroyed-form events.
+ * - observe resources related to the observed targets.
+ * This is done via watchResources/unwatchResources methods, and
+ * resource-available-form/resource-updated-form/resource-destroyed-form events.
+ * Note that these events are also emited on both the watcher actor,
+ * for resources observed from the parent process, as well as on the
+ * target actors, when the resources are observed from the target's process or thread.
+ *
+ * @param {DevToolsServerConnection} conn
+ * The connection to use in order to communicate back to the client.
+ * @param {object} sessionContext
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * @param {Number} sessionContext.browserId: If this is a "browser-element" context type,
+ * the "browserId" of the <browser> element we would like to debug.
+ * @param {Boolean} sessionContext.isServerTargetSwitchingEnabled: Flag to to know if we should
+ * spawn new top level targets for the debugged context.
+ */
+ constructor(conn, sessionContext) {
+ super(conn, watcherSpec);
+ this._sessionContext = sessionContext;
+ if (sessionContext.type == SESSION_TYPES.BROWSER_ELEMENT) {
+ // Retrieve the <browser> element for the given browser ID
+ const browsingContext = BrowsingContext.getCurrentTopByBrowserId(
+ sessionContext.browserId
+ );
+ if (!browsingContext) {
+ throw new Error(
+ "Unable to retrieve the <browser> element for browserId=" +
+ sessionContext.browserId
+ );
+ }
+ this._browserElement = browsingContext.embedderElement;
+ }
+
+ // Sometimes we get iframe targets before the top-level targets
+ // mostly when doing bfcache navigations, lets cache the early iframes targets and
+ // flush them after the top-level target is available. See Bug 1726568 for details.
+ this._earlyIframeTargets = {};
+
+ // All currently available WindowGlobal target's form, keyed by `innerWindowId`.
+ //
+ // This helps to:
+ // - determine if the iframe targets are early or not.
+ // i.e. if it is notified before its parent target is available.
+ // - notify the destruction of all children targets when a parent is destroyed.
+ // i.e. have a reliable order of destruction between parent and children.
+ //
+ // Note that there should be just one top-level window target at a time,
+ // but there are certain cases when a new target is available before the
+ // old target is destroyed.
+ this._currentWindowGlobalTargets = new Map();
+ }
+
+ get sessionContext() {
+ return this._sessionContext;
+ }
+
+ /**
+ * If we are debugging only one Tab or Document, returns its BrowserElement.
+ * For Tabs, it will be the <browser> element used to load the web page.
+ *
+ * This is typicaly used to fetch:
+ * - its `browserId` attribute, which uniquely defines it,
+ * - its `browsingContextID` or `browsingContext`, which helps inspecting its content.
+ */
+ get browserElement() {
+ return this._browserElement;
+ }
+
+ getAllBrowsingContexts(options) {
+ return getAllBrowsingContextsForContext(this.sessionContext, options);
+ }
+
+ /**
+ * Helper to know if the context we are debugging has been already destroyed
+ */
+ isContextDestroyed() {
+ if (this.sessionContext.type == "browser-element") {
+ return !this.browserElement.browsingContext;
+ } else if (this.sessionContext.type == "webextension") {
+ return !BrowsingContext.get(this.sessionContext.addonBrowsingContextID);
+ } else if (this.sessionContext.type == "all") {
+ return false;
+ }
+ throw new Error(
+ "Unsupported session context type: " + this.sessionContext.type
+ );
+ }
+
+ destroy() {
+ // Force unwatching for all types, even if we weren't watching.
+ // This is fine as unwatchTarget is NOOP if we weren't already watching for this target type.
+ for (const targetType of Object.values(Targets.TYPES)) {
+ this.unwatchTargets(targetType);
+ }
+ this.unwatchResources(Object.values(Resources.TYPES));
+
+ WatcherRegistry.unregisterWatcher(this);
+
+ // Destroy the actor at the end so that its actorID keeps being defined.
+ super.destroy();
+ }
+
+ /*
+ * Get the list of the currently watched resources for this watcher.
+ *
+ * @return Array<String>
+ * Returns the list of currently watched resource types.
+ */
+ get sessionData() {
+ return WatcherRegistry.getSessionData(this);
+ }
+
+ form() {
+ return {
+ actor: this.actorID,
+ // The resources and target traits should be removed all at the same time since the
+ // client has generic ways to deal with all of them (See Bug 1680280).
+ traits: {
+ ...this.sessionContext.supportedTargets,
+ resources: this.sessionContext.supportedResources,
+ },
+ };
+ }
+
+ /**
+ * Start watching for a new target type.
+ *
+ * This will instantiate Target Actors for existing debugging context of this type,
+ * but will also create actors as context of this type get created.
+ * The actors are notified to the client via "target-available-form" RDP events.
+ * We also notify about target actors destruction via "target-destroyed-form".
+ * Note that we are guaranteed to receive all existing target actor by the time this method
+ * resolves.
+ *
+ * @param {string} targetType
+ * Type of context to observe. See Targets.TYPES object.
+ */
+ async watchTargets(targetType) {
+ WatcherRegistry.watchTargets(this, targetType);
+
+ const targetHelperModule = TARGET_HELPERS[targetType];
+ // Await the registration in order to ensure receiving the already existing targets
+ await targetHelperModule.createTargets(this);
+ }
+
+ /**
+ * Stop watching for a given target type.
+ *
+ * @param {string} targetType
+ * Type of context to observe. See Targets.TYPES object.
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+ unwatchTargets(targetType, options = {}) {
+ const isWatchingTargets = WatcherRegistry.unwatchTargets(
+ this,
+ targetType,
+ options
+ );
+ if (!isWatchingTargets) {
+ return;
+ }
+
+ const targetHelperModule = TARGET_HELPERS[targetType];
+ targetHelperModule.destroyTargets(this, options);
+
+ // Unregister the JS Window Actor if there is no more DevTools code observing any target/resource,
+ // unless we're switching mode (having both condition at the same time should only
+ // happen in tests).
+ if (!options.isModeSwitching) {
+ WatcherRegistry.maybeUnregisteringJSWindowActor();
+ }
+ }
+
+ /**
+ * Flush any early iframe targets relating to this top level
+ * window target.
+ * @param {number} topInnerWindowID
+ */
+ _flushIframeTargets(topInnerWindowID) {
+ while (this._earlyIframeTargets[topInnerWindowID]?.length > 0) {
+ const actor = this._earlyIframeTargets[topInnerWindowID].shift();
+ this.emit("target-available-form", actor);
+ }
+ }
+
+ /**
+ * Called by a Watcher module, whenever a new target is available
+ */
+ notifyTargetAvailable(actor) {
+ // Emit immediately for worker, process & extension targets
+ // as they don't have a parent browsing context.
+ if (!actor.traits?.isBrowsingContext) {
+ this.emit("target-available-form", actor);
+ return;
+ }
+
+ // If isBrowsingContext trait is true, we are processing a WindowGlobalTarget.
+ // (this trait should be renamed)
+ this._currentWindowGlobalTargets.set(actor.innerWindowId, actor);
+
+ // The top-level is always the same for the browser-toolbox
+ if (this.sessionContext.type == "all") {
+ this.emit("target-available-form", actor);
+ return;
+ }
+
+ if (actor.isTopLevelTarget) {
+ this.emit("target-available-form", actor);
+ // Flush any existing early iframe targets
+ this._flushIframeTargets(actor.innerWindowId);
+
+ if (this.sessionContext.type == SESSION_TYPES.BROWSER_ELEMENT) {
+ this.updateDomainSessionDataForServiceWorkers(actor.url);
+ }
+ } else if (this._currentWindowGlobalTargets.has(actor.topInnerWindowId)) {
+ // Emit the event immediately if the top-level target is already available
+ this.emit("target-available-form", actor);
+ } else if (this._earlyIframeTargets[actor.topInnerWindowId]) {
+ // Add the early iframe target to the list of other early targets.
+ this._earlyIframeTargets[actor.topInnerWindowId].push(actor);
+ } else {
+ // Set the first early iframe target
+ this._earlyIframeTargets[actor.topInnerWindowId] = [actor];
+ }
+ }
+
+ /**
+ * Called by a Watcher module, whenever a target has been destroyed
+ *
+ * @param {object} actor
+ * the actor form of the target being destroyed
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+ async notifyTargetDestroyed(actor, options = {}) {
+ // Emit immediately for worker, process & extension targets
+ // as they don't have a parent browsing context.
+ if (!actor.innerWindowId) {
+ this.emit("target-destroyed-form", actor, options);
+ return;
+ }
+ // Flush all iframe targets if we are destroying a top level target.
+ if (actor.isTopLevelTarget) {
+ // First compute the list of children actors, as notifyTargetDestroy will mutate _currentWindowGlobalTargets
+ const childrenActors = [
+ ...this._currentWindowGlobalTargets.values(),
+ ].filter(
+ form =>
+ form.topInnerWindowId == actor.innerWindowId &&
+ // Ignore the top level target itself, because its topInnerWindowId will be its innerWindowId
+ form.innerWindowId != actor.innerWindowId
+ );
+ childrenActors.map(form => this.notifyTargetDestroyed(form, options));
+ }
+ if (this._earlyIframeTargets[actor.innerWindowId]) {
+ delete this._earlyIframeTargets[actor.innerWindowId];
+ }
+ this._currentWindowGlobalTargets.delete(actor.innerWindowId);
+ const documentEventWatcher = Resources.getResourceWatcher(
+ this,
+ Resources.TYPES.DOCUMENT_EVENT
+ );
+ // If we have a Watcher class instantiated, ensure that target-destroyed is sent
+ // *after* DOCUMENT_EVENT's will-navigate. Otherwise this resource will have an undefined
+ // `targetFront` attribute, as it is associated with the target from which we navigate
+ // and not the one we navigate to.
+ //
+ // About documentEventWatcher check: We won't have any watcher class if we aren't
+ // using server side Watcher classes.
+ // i.e. when we are using the legacy listener for DOCUMENT_EVENT.
+ // This is still the case for all toolboxes but the one for local and remote tabs.
+ //
+ // About isServerTargetSwitchingEnabled check: if we are using the watcher class
+ // we may still use client side target, which will still use legacy listeners for
+ // will-navigate and so will-navigate will be emitted by the target actor itself.
+ //
+ // About isTopLevelTarget check: only top level targets emit will-navigate,
+ // so there is no reason to delay target-destroy for remote iframes.
+ if (
+ documentEventWatcher &&
+ this.sessionContext.isServerTargetSwitchingEnabled &&
+ actor.isTopLevelTarget
+ ) {
+ await documentEventWatcher.onceWillNavigateIsEmitted(actor.innerWindowId);
+ }
+ this.emit("target-destroyed-form", actor, options);
+ }
+
+ /**
+ * Given a browsingContextID, returns its parent browsingContextID. Returns null if a
+ * parent browsing context couldn't be found. Throws if the browsing context
+ * corresponding to the passed browsingContextID couldn't be found.
+ *
+ * @param {Integer} browsingContextID
+ * @returns {Integer|null}
+ */
+ getParentBrowsingContextID(browsingContextID) {
+ const browsingContext = BrowsingContext.get(browsingContextID);
+ if (!browsingContext) {
+ throw new Error(
+ `BrowsingContext with ID=${browsingContextID} doesn't exist.`
+ );
+ }
+ // Top-level documents of tabs, loaded in a <browser> element expose a null `parent`.
+ // i.e. Their BrowsingContext has no parent and is considered top level.
+ // But... in the context of the Browser Toolbox, we still consider them as child of the browser window.
+ // So, for them, fallback on `embedderWindowGlobal`, which will typically be the WindowGlobal for browser.xhtml.
+ if (browsingContext.parent) {
+ return browsingContext.parent.id;
+ }
+ if (browsingContext.embedderWindowGlobal) {
+ return browsingContext.embedderWindowGlobal.browsingContext.id;
+ }
+ return null;
+ }
+
+ /**
+ * Called by Resource Watchers, when new resources are available, updated or destroyed.
+ *
+ * @param String updateType
+ * Can be "available", "updated" or "destroyed"
+ * @param Array<json> resources
+ * List of all resource's form. A resource is a JSON object piped over to the client.
+ * It can contain actor IDs, actor forms, to be manually marshalled by the client.
+ */
+ notifyResources(updateType, resources) {
+ if (resources.length === 0) {
+ // Don't try to emit if the resources array is empty.
+ return;
+ }
+
+ if (this.sessionContext.type == "webextension") {
+ this._overrideResourceBrowsingContextForWebExtension(resources);
+ }
+
+ this.emit(`resource-${updateType}-form`, resources);
+ }
+
+ /**
+ * For WebExtension, we have to hack all resource's browsingContextID
+ * in order to ensure emitting them with the fixed, original browsingContextID
+ * related to the fallback document created by devtools which always exists.
+ * The target's form will always be relating to that BrowsingContext IDs (browsing context ID and inner window id).
+ * Even if the target switches internally to another document via WindowGlobalTargetActor._setWindow.
+ *
+ * @param {Array<Objects>} List of resources
+ */
+ _overrideResourceBrowsingContextForWebExtension(resources) {
+ resources.forEach(resource => {
+ resource.browsingContextID = this.sessionContext.addonBrowsingContextID;
+ });
+ }
+
+ /**
+ * Try to retrieve a parent process TargetActor which is ignored by the
+ * TARGET_HELPERS. Examples:
+ * - top level target for the browser toolbox
+ * - xpcshell target for xpcshell debugging
+ *
+ * See comment in `watchResources`.
+ *
+ * @return {TargetActor|null} Matching target actor if any, null otherwise.
+ */
+ getTargetActorInParentProcess() {
+ if (TargetActorRegistry.xpcShellTargetActor) {
+ return TargetActorRegistry.xpcShellTargetActor;
+ }
+
+ // Note: For browser-element debugging, the WindowGlobalTargetActor returned here is created
+ // for a parent process page and lives in the parent process.
+ const actors = TargetActorRegistry.getTargetActors(
+ this.sessionContext,
+ this.conn.prefix
+ );
+
+ switch (this.sessionContext.type) {
+ case "all":
+ return actors.find(actor => actor.typeName === "parentProcessTarget");
+ case "browser-element":
+ case "webextension":
+ // All target actors for browser-element and webextension sessions
+ // should be created using the JS Window actors.
+ return null;
+ default:
+ throw new Error(
+ "Unsupported session context type: " + this.sessionContext.type
+ );
+ }
+ }
+
+ /**
+ * Start watching for a list of resource types.
+ * This should only resolve once all "already existing" resources of these types
+ * are notified to the client via resource-available-form event on related target actors.
+ *
+ * @param {Array<string>} resourceTypes
+ * List of all types to listen to.
+ */
+ async watchResources(resourceTypes) {
+ // First process resources which have to be listened from the parent process
+ // (the watcher actor always runs in the parent process)
+ await Resources.watchResources(
+ this,
+ Resources.getParentProcessResourceTypes(resourceTypes)
+ );
+
+ // Bail out early if all resources were watched from parent process.
+ // In this scenario, we do not need to update these resource types in the WatcherRegistry
+ // as targets do not care about them.
+ if (!Resources.hasResourceTypesForTargets(resourceTypes)) {
+ return;
+ }
+
+ WatcherRegistry.watchResources(this, resourceTypes);
+
+ // Fetch resources from all existing targets
+ for (const targetType in TARGET_HELPERS) {
+ // We process frame targets even if we aren't watching them,
+ // because frame target helper codepath handles the top level target, if it runs in the *content* process.
+ // It will do another check to `isWatchingTargets(FRAME)` internally.
+ // Note that the workaround at the end of this method, using TargetActorRegistry
+ // is specific to top level target running in the *parent* process.
+ if (
+ !WatcherRegistry.isWatchingTargets(this, targetType) &&
+ targetType != Targets.TYPES.FRAME
+ ) {
+ continue;
+ }
+ const targetResourceTypes = Resources.getResourceTypesForTargetType(
+ resourceTypes,
+ targetType
+ );
+ if (!targetResourceTypes.length) {
+ continue;
+ }
+ const targetHelperModule = TARGET_HELPERS[targetType];
+ await targetHelperModule.addOrSetSessionDataEntry({
+ watcher: this,
+ type: "resources",
+ entries: targetResourceTypes,
+ updateType: "add",
+ });
+ }
+
+ /*
+ * The Watcher actor doesn't support watching the top level target
+ * (bug 1644397 and possibly some other followup).
+ *
+ * Because of that, we miss reaching these targets in the previous lines of this function.
+ * Since all BrowsingContext target actors register themselves to the TargetActorRegistry,
+ * we use it here in order to reach those missing targets, which are running in the
+ * parent process (where this WatcherActor lives as well):
+ * - the parent process target (which inherits from WindowGlobalTargetActor)
+ * - top level tab target for documents loaded in the parent process (e.g. about:robots).
+ * When the tab loads document in the content process, the FrameTargetHelper will
+ * reach it via the JSWindowActor API. Even if it uses MessageManager for anything
+ * else (RDP packet forwarding, creation and destruction).
+ *
+ * We will eventually get rid of this code once all targets are properly supported by
+ * the Watcher Actor and we have target helpers for all of them.
+ */
+ const targetActor = this.getTargetActorInParentProcess();
+ if (targetActor) {
+ const targetActorResourceTypes = Resources.getResourceTypesForTargetType(
+ resourceTypes,
+ targetActor.targetType
+ );
+ await targetActor.addOrSetSessionDataEntry(
+ "resources",
+ targetActorResourceTypes,
+ false,
+ "add"
+ );
+ }
+ }
+
+ /**
+ * Stop watching for a list of resource types.
+ *
+ * @param {Array<string>} resourceTypes
+ * List of all types to listen to.
+ */
+ unwatchResources(resourceTypes) {
+ // First process resources which are listened from the parent process
+ // (the watcher actor always runs in the parent process)
+ Resources.unwatchResources(
+ this,
+ Resources.getParentProcessResourceTypes(resourceTypes)
+ );
+
+ // Bail out early if all resources were all watched from parent process.
+ // In this scenario, we do not need to update these resource types in the WatcherRegistry
+ // as targets do not care about them.
+ if (!Resources.hasResourceTypesForTargets(resourceTypes)) {
+ return;
+ }
+
+ const isWatchingResources = WatcherRegistry.unwatchResources(
+ this,
+ resourceTypes
+ );
+ if (!isWatchingResources) {
+ return;
+ }
+
+ // Prevent trying to unwatch when the related BrowsingContext has already
+ // been destroyed
+ if (!this.isContextDestroyed()) {
+ for (const targetType in TARGET_HELPERS) {
+ // Frame target helper handles the top level target, if it runs in the content process
+ // so we should always process it. It does a second check to isWatchingTargets.
+ if (
+ !WatcherRegistry.isWatchingTargets(this, targetType) &&
+ targetType != Targets.TYPES.FRAME
+ ) {
+ continue;
+ }
+ const targetResourceTypes = Resources.getResourceTypesForTargetType(
+ resourceTypes,
+ targetType
+ );
+ if (!targetResourceTypes.length) {
+ continue;
+ }
+ const targetHelperModule = TARGET_HELPERS[targetType];
+ targetHelperModule.removeSessionDataEntry({
+ watcher: this,
+ type: "resources",
+ entries: targetResourceTypes,
+ });
+ }
+ }
+
+ // See comment in watchResources.
+ const targetActor = this.getTargetActorInParentProcess();
+ if (targetActor) {
+ const targetActorResourceTypes = Resources.getResourceTypesForTargetType(
+ resourceTypes,
+ targetActor.targetType
+ );
+ targetActor.removeSessionDataEntry("resources", targetActorResourceTypes);
+ }
+
+ // Unregister the JS Window Actor if there is no more DevTools code observing any target/resource
+ WatcherRegistry.maybeUnregisteringJSWindowActor();
+ }
+
+ clearResources(resourceTypes) {
+ // First process resources which have to be listened from the parent process
+ // (the watcher actor always runs in the parent process)
+ // TODO: content process / worker thread resources are not cleared. See Bug 1774573
+ Resources.clearResources(
+ this,
+ Resources.getParentProcessResourceTypes(resourceTypes)
+ );
+ }
+
+ /**
+ * Returns the network actor.
+ *
+ * @return {Object} actor
+ * The network actor.
+ */
+ getNetworkParentActor() {
+ if (!this._networkParentActor) {
+ this._networkParentActor = new NetworkParentActor(this);
+ }
+
+ return this._networkParentActor;
+ }
+
+ /**
+ * Returns the blackboxing actor.
+ *
+ * @return {Object} actor
+ * The blackboxing actor.
+ */
+ getBlackboxingActor() {
+ if (!this._blackboxingActor) {
+ this._blackboxingActor = new BlackboxingActor(this);
+ }
+
+ return this._blackboxingActor;
+ }
+
+ /**
+ * Returns the breakpoint list actor.
+ *
+ * @return {Object} actor
+ * The breakpoint list actor.
+ */
+ getBreakpointListActor() {
+ if (!this._breakpointListActor) {
+ this._breakpointListActor = new BreakpointListActor(this);
+ }
+
+ return this._breakpointListActor;
+ }
+
+ /**
+ * Returns the target configuration actor.
+ *
+ * @return {Object} actor
+ * The configuration actor.
+ */
+ getTargetConfigurationActor() {
+ if (!this._targetConfigurationListActor) {
+ this._targetConfigurationListActor = new TargetConfigurationActor(this);
+ }
+ return this._targetConfigurationListActor;
+ }
+
+ /**
+ * Returns the thread configuration actor.
+ *
+ * @return {Object} actor
+ * The configuration actor.
+ */
+ getThreadConfigurationActor() {
+ if (!this._threadConfigurationListActor) {
+ this._threadConfigurationListActor = new ThreadConfigurationActor(this);
+ }
+ return this._threadConfigurationListActor;
+ }
+
+ /**
+ * Server internal API, called by other actors, but not by the client.
+ * Used to agrement some new entries for a given data type (watchers target, resources,
+ * breakpoints,...)
+ *
+ * @param {String} type
+ * Data type to contribute to.
+ * @param {Array<*>} entries
+ * List of values to add or set for this data type.
+ * @param {String} updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+ async addOrSetDataEntry(type, entries, updateType) {
+ WatcherRegistry.addOrSetSessionDataEntry(this, type, entries, updateType);
+
+ await Promise.all(
+ Object.values(Targets.TYPES)
+ .filter(
+ targetType =>
+ // We process frame targets even if we aren't watching them,
+ // because frame target helper codepath handles the top level target, if it runs in the *content* process.
+ // It will do another check to `isWatchingTargets(FRAME)` internally.
+ // Note that the workaround at the end of this method, using TargetActorRegistry
+ // is specific to top level target running in the *parent* process.
+ WatcherRegistry.isWatchingTargets(this, targetType) ||
+ targetType === Targets.TYPES.FRAME
+ )
+ .map(async targetType => {
+ const targetHelperModule = TARGET_HELPERS[targetType];
+ await targetHelperModule.addOrSetSessionDataEntry({
+ watcher: this,
+ type,
+ entries,
+ updateType,
+ });
+ })
+ );
+
+ // See comment in watchResources
+ const targetActor = this.getTargetActorInParentProcess();
+ if (targetActor) {
+ await targetActor.addOrSetSessionDataEntry(
+ type,
+ entries,
+ false,
+ updateType
+ );
+ }
+ }
+
+ /**
+ * Server internal API, called by other actors, but not by the client.
+ * Used to remve some existing entries for a given data type (watchers target, resources,
+ * breakpoints,...)
+ *
+ * @param {String} type
+ * Data type to modify.
+ * @param {Array<*>} entries
+ * List of values to remove from this data type.
+ */
+ removeDataEntry(type, entries) {
+ WatcherRegistry.removeSessionDataEntry(this, type, entries);
+
+ Object.values(Targets.TYPES)
+ .filter(
+ targetType =>
+ // See comment in addOrSetDataEntry
+ WatcherRegistry.isWatchingTargets(this, targetType) ||
+ targetType === Targets.TYPES.FRAME
+ )
+ .forEach(targetType => {
+ const targetHelperModule = TARGET_HELPERS[targetType];
+ targetHelperModule.removeSessionDataEntry({
+ watcher: this,
+ type,
+ entries,
+ });
+ });
+
+ // See comment in addOrSetDataEntry
+ const targetActor = this.getTargetActorInParentProcess();
+ if (targetActor) {
+ targetActor.removeSessionDataEntry(type, entries);
+ }
+ }
+
+ /**
+ * Retrieve the current watched data for the provided type.
+ *
+ * @param {String} type
+ * Data type to retrieve.
+ */
+ getSessionDataForType(type) {
+ return this.sessionData?.[type];
+ }
+
+ /**
+ * Special code dedicated to Service Worker debugging.
+ * This will notify the Service Worker JS Process Actors about the new top level page domain.
+ * So that we start tracking that domain's workers.
+ *
+ * @param {String} newTargetUrl
+ */
+ async updateDomainSessionDataForServiceWorkers(newTargetUrl) {
+ let host = "";
+ // Accessing `host` can throw on some URLs with no valid host like about:home.
+ // In such scenario, reset the host to an empty string.
+ try {
+ host = new URL(newTargetUrl).host;
+ } catch (e) {}
+
+ WatcherRegistry.addOrSetSessionDataEntry(
+ this,
+ "browser-element-host",
+ [host],
+ "set"
+ );
+
+ // This SessionData attribute is only used when debugging service workers.
+ // Avoid instantiating the JS Process Actors if we aren't watching for SW,
+ // or if we aren't watching for them just yet.
+ // But still update the WatcherRegistry, so that when we start watching
+ // and instantiate the target, the host will be set to the right value.
+ //
+ // Note that it is very important to avoid calling Service worker target helper's
+ // addOrSetSessionDataEntry. Otherwise, when we aren't watching for SW at all,
+ // we won't call destroyTargets on watcher actor destruction,
+ // and as a consequence never unregister the js process actor.
+ if (
+ !WatcherRegistry.isWatchingTargets(this, Targets.TYPES.SERVICE_WORKER)
+ ) {
+ return;
+ }
+
+ const targetHelperModule = TARGET_HELPERS[Targets.TYPES.SERVICE_WORKER];
+ await targetHelperModule.addOrSetSessionDataEntry({
+ watcher: this,
+ type: "browser-element-host",
+ entries: [host],
+ updateType: "set",
+ });
+ }
+};
diff --git a/devtools/server/actors/watcher/SessionDataHelpers.jsm b/devtools/server/actors/watcher/SessionDataHelpers.jsm
new file mode 100644
index 0000000000..c70df1744f
--- /dev/null
+++ b/devtools/server/actors/watcher/SessionDataHelpers.jsm
@@ -0,0 +1,244 @@
+/* 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";
+
+/**
+ * Helper module alongside WatcherRegistry, which focus on updating the "sessionData" object.
+ * This object is shared across processes and threads and have to be maintained in all these runtimes.
+ */
+
+var EXPORTED_SYMBOLS = ["SessionDataHelpers"];
+
+const lazy = {};
+
+if (typeof module == "object") {
+ // Allow this JSM to also be loaded as a CommonJS module
+ // Because this module is used from the worker thread,
+ // (via target-actor-mixin), and workers can't load JSMs via ChromeUtils.import.
+ loader.lazyRequireGetter(
+ lazy,
+ "validateBreakpointLocation",
+ "resource://devtools/shared/validate-breakpoint.jsm",
+ true
+ );
+
+ loader.lazyRequireGetter(
+ lazy,
+ "validateEventBreakpoint",
+ "resource://devtools/server/actors/utils/event-breakpoints.js",
+ true
+ );
+} else {
+ ChromeUtils.defineLazyGetter(lazy, "validateBreakpointLocation", () => {
+ return ChromeUtils.import(
+ "resource://devtools/shared/validate-breakpoint.jsm"
+ ).validateBreakpointLocation;
+ });
+ ChromeUtils.defineLazyGetter(lazy, "validateEventBreakpoint", () => {
+ const { loader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ return loader.require(
+ "resource://devtools/server/actors/utils/event-breakpoints.js"
+ ).validateEventBreakpoint;
+ });
+}
+
+// List of all arrays stored in `sessionData`, which are replicated across processes and threads
+const SUPPORTED_DATA = {
+ BLACKBOXING: "blackboxing",
+ BREAKPOINTS: "breakpoints",
+ BROWSER_ELEMENT_HOST: "browser-element-host",
+ XHR_BREAKPOINTS: "xhr-breakpoints",
+ EVENT_BREAKPOINTS: "event-breakpoints",
+ RESOURCES: "resources",
+ TARGET_CONFIGURATION: "target-configuration",
+ THREAD_CONFIGURATION: "thread-configuration",
+ TARGETS: "targets",
+};
+
+// Optional function, if data isn't a primitive data type in order to produce a key
+// for the given data entry
+const DATA_KEY_FUNCTION = {
+ [SUPPORTED_DATA.BLACKBOXING]({ url, range }) {
+ return (
+ url +
+ (range
+ ? `:${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`
+ : "")
+ );
+ },
+ [SUPPORTED_DATA.BREAKPOINTS]({ location }) {
+ lazy.validateBreakpointLocation(location);
+ const { sourceUrl, sourceId, line, column } = location;
+ return `${sourceUrl}:${sourceId}:${line}:${column}`;
+ },
+ [SUPPORTED_DATA.TARGET_CONFIGURATION]({ key }) {
+ // Configuration data entries are { key, value } objects, `key` can be used
+ // as the unique identifier for the entry.
+ return key;
+ },
+ [SUPPORTED_DATA.THREAD_CONFIGURATION]({ key }) {
+ // See target configuration comment
+ return key;
+ },
+ [SUPPORTED_DATA.XHR_BREAKPOINTS]({ path, method }) {
+ if (typeof path != "string") {
+ throw new Error(
+ `XHR Breakpoints expect to have path string, got ${typeof path} instead.`
+ );
+ }
+ if (typeof method != "string") {
+ throw new Error(
+ `XHR Breakpoints expect to have method string, got ${typeof method} instead.`
+ );
+ }
+ return `${path}:${method}`;
+ },
+ [SUPPORTED_DATA.EVENT_BREAKPOINTS](id) {
+ if (typeof id != "string") {
+ throw new Error(
+ `Event Breakpoints expect the id to be a string , got ${typeof id} instead.`
+ );
+ }
+ if (!lazy.validateEventBreakpoint(id)) {
+ throw new Error(
+ `The id string should be a valid event breakpoint id, ${id} is not.`
+ );
+ }
+ return id;
+ },
+};
+// Optional validation method to assert the shape of each session data entry
+const DATA_VALIDATION_FUNCTION = {
+ [SUPPORTED_DATA.BREAKPOINTS]({ location }) {
+ lazy.validateBreakpointLocation(location);
+ },
+ [SUPPORTED_DATA.XHR_BREAKPOINTS]({ path, method }) {
+ if (typeof path != "string") {
+ throw new Error(
+ `XHR Breakpoints expect to have path string, got ${typeof path} instead.`
+ );
+ }
+ if (typeof method != "string") {
+ throw new Error(
+ `XHR Breakpoints expect to have method string, got ${typeof method} instead.`
+ );
+ }
+ },
+ [SUPPORTED_DATA.EVENT_BREAKPOINTS](id) {
+ if (typeof id != "string") {
+ throw new Error(
+ `Event Breakpoints expect the id to be a string , got ${typeof id} instead.`
+ );
+ }
+ if (!lazy.validateEventBreakpoint(id)) {
+ throw new Error(
+ `The id string should be a valid event breakpoint id, ${id} is not.`
+ );
+ }
+ },
+};
+
+function idFunction(v) {
+ if (typeof v != "string") {
+ throw new Error(
+ `Expect data entry values to be string, or be using custom data key functions. Got ${typeof v} type instead.`
+ );
+ }
+ return v;
+}
+
+const SessionDataHelpers = {
+ SUPPORTED_DATA,
+
+ /**
+ * Add new values to the shared "sessionData" object.
+ *
+ * @param Object sessionData
+ * The data object to update.
+ * @param string type
+ * The type of data to be added
+ * @param Array<Object> entries
+ * The values to be added to this type of data
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+ addOrSetSessionDataEntry(sessionData, type, entries, updateType) {
+ const validationFunction = DATA_VALIDATION_FUNCTION[type];
+ if (validationFunction) {
+ entries.forEach(validationFunction);
+ }
+
+ // When we are replacing the whole entries, things are significantly simplier
+ if (updateType == "set") {
+ sessionData[type] = entries;
+ return;
+ }
+
+ if (!sessionData[type]) {
+ sessionData[type] = [];
+ }
+ const toBeAdded = [];
+ const keyFunction = DATA_KEY_FUNCTION[type] || idFunction;
+ for (const entry of entries) {
+ const existingIndex = sessionData[type].findIndex(existingEntry => {
+ return keyFunction(existingEntry) === keyFunction(entry);
+ });
+ if (existingIndex === -1) {
+ // New entry.
+ toBeAdded.push(entry);
+ } else {
+ // Existing entry, update the value. This is relevant if the data-entry
+ // is not a primitive data-type, and the value can change for the same
+ // key.
+ sessionData[type][existingIndex] = entry;
+ }
+ }
+ sessionData[type].push(...toBeAdded);
+ },
+
+ /**
+ * Remove values from the shared "sessionData" object.
+ *
+ * @param Object sessionData
+ * The data object to update.
+ * @param string type
+ * The type of data to be remove
+ * @param Array<Object> entries
+ * The values to be removed from this type of data
+ * @return Boolean
+ * True, if at least one entries existed and has been removed.
+ * False, if none of the entries existed and none has been removed.
+ */
+ removeSessionDataEntry(sessionData, type, entries) {
+ let includesAtLeastOne = false;
+ const keyFunction = DATA_KEY_FUNCTION[type] || idFunction;
+ for (const entry of entries) {
+ const idx = sessionData[type]
+ ? sessionData[type].findIndex(existingEntry => {
+ return keyFunction(existingEntry) === keyFunction(entry);
+ })
+ : -1;
+ if (idx !== -1) {
+ sessionData[type].splice(idx, 1);
+ includesAtLeastOne = true;
+ }
+ }
+ if (!includesAtLeastOne) {
+ return false;
+ }
+
+ return true;
+ },
+};
+
+// Allow this JSM to also be loaded as a CommonJS module
+// Because this module is used from the worker thread,
+// (via target-actor-mixin), and workers can't load JSMs.
+if (typeof module == "object") {
+ module.exports.SessionDataHelpers = SessionDataHelpers;
+}
diff --git a/devtools/server/actors/watcher/WatcherRegistry.sys.mjs b/devtools/server/actors/watcher/WatcherRegistry.sys.mjs
new file mode 100644
index 0000000000..1068a253c9
--- /dev/null
+++ b/devtools/server/actors/watcher/WatcherRegistry.sys.mjs
@@ -0,0 +1,397 @@
+/* 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/. */
+
+/**
+ * Helper module around `sharedData` object that helps storing the state
+ * of all observed Targets and Resources, that, for all DevTools connections.
+ * Here is a few words about the C++ implementation of sharedData:
+ * https://searchfox.org/mozilla-central/rev/bc3600def806859c31b2c7ac06e3d69271052a89/dom/ipc/SharedMap.h#30-55
+ *
+ * We may have more than one DevToolsServer and one server may have more than one
+ * client. This module will be the single source of truth in the parent process,
+ * in order to know which targets/resources are currently observed. It will also
+ * be used to declare when something starts/stops being observed.
+ *
+ * `sharedData` is a platform API that helps sharing JS Objects across processes.
+ * We use it in order to communicate to the content process which targets and resources
+ * should be observed. Content processes read this data only once, as soon as they are created.
+ * It isn't used beyond this point. Content processes are not going to update it.
+ * We will notify about changes in observed targets and resources for already running
+ * processes by some other means. (Via JS Window Actor queries "DevTools:(un)watch(Resources|Target)")
+ * This means that only this module will update the "DevTools:watchedPerWatcher" value.
+ * From the parent process, we should be going through this module to fetch the data,
+ * while from the content process, we will read `sharedData` directly.
+ */
+
+import { ActorManagerParent } from "resource://gre/modules/ActorManagerParent.sys.mjs";
+
+const { SessionDataHelpers } = ChromeUtils.import(
+ "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm"
+);
+
+const { SUPPORTED_DATA } = SessionDataHelpers;
+const SUPPORTED_DATA_TYPES = Object.values(SUPPORTED_DATA);
+
+// Define the Map that will be saved in `sharedData`.
+// It is keyed by WatcherActor ID and values contains following attributes:
+// - targets: Set of strings, refering to target types to be listened to
+// - resources: Set of strings, refering to resource types to be observed
+// - sessionContext Object, The Session Context to help know what is debugged.
+// See devtools/server/actors/watcher/session-context.js
+// - connectionPrefix: The DevToolsConnection prefix of the watcher actor. Used to compute new actor ID in the content processes.
+//
+// Unfortunately, `sharedData` is subject to race condition and may have side effect
+// when read/written from multiple places in the same process,
+// which is why this map should be considered as the single source of truth.
+const sessionDataByWatcherActor = new Map();
+
+// In parallel to the previous map, keep all the WatcherActor keyed by the same WatcherActor ID,
+// the WatcherActor ID. We don't (and can't) propagate the WatcherActor instances to the content
+// processes, but still would like to match them by their ID.
+const watcherActors = new Map();
+
+// Name of the attribute into which we save this Map in `sharedData` object.
+const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher";
+
+/**
+ * Use `sharedData` to allow processes, early during their creation,
+ * to know which resources should be listened to. This will be read
+ * from the Target actor, when it gets created early during process start,
+ * in order to start listening to the expected resource types.
+ */
+function persistMapToSharedData() {
+ Services.ppmm.sharedData.set(SHARED_DATA_KEY_NAME, sessionDataByWatcherActor);
+ // Request to immediately flush the data to the content processes in order to prevent
+ // races (bug 1644649). Otherwise content process may have outdated sharedData
+ // and try to create targets for Watcher actor that already stopped watching for targets.
+ Services.ppmm.sharedData.flush();
+}
+
+export const WatcherRegistry = {
+ /**
+ * Tells if a given watcher currently watches for a given target type.
+ *
+ * @param WatcherActor watcher
+ * The WatcherActor which should be listening.
+ * @param string targetType
+ * The new target type to query.
+ * @return boolean
+ * Returns true if already watching.
+ */
+ isWatchingTargets(watcher, targetType) {
+ const sessionData = this.getSessionData(watcher);
+ return !!sessionData?.targets?.includes(targetType);
+ },
+
+ /**
+ * Retrieve the data saved into `sharedData` that is used to know
+ * about which type of targets and resources we care listening about.
+ * `sessionDataByWatcherActor` is saved into `sharedData` after each mutation,
+ * but `sessionDataByWatcherActor` is the source of truth.
+ *
+ * @param WatcherActor watcher
+ * The related WatcherActor which starts/stops observing.
+ * @param object options (optional)
+ * A dictionary object with `createData` boolean attribute.
+ * If this attribute is set to true, we create the data structure in the Map
+ * if none exists for this prefix.
+ */
+ getSessionData(watcher, { createData = false } = {}) {
+ // Use WatcherActor ID as a key as we may have multiple clients willing to watch for targets.
+ // For example, a Browser Toolbox debugging everything and a Content Toolbox debugging
+ // just one tab. We might also have multiple watchers, on the same connection when using about:debugging.
+ const watcherActorID = watcher.actorID;
+ let sessionData = sessionDataByWatcherActor.get(watcherActorID);
+ if (!sessionData && createData) {
+ sessionData = {
+ // The "session context" object help understand what should be debugged and which target should be created.
+ // See WatcherActor constructor for more info.
+ sessionContext: watcher.sessionContext,
+ // The DevToolsServerConnection prefix will be used to compute actor IDs created in the content process
+ connectionPrefix: watcher.conn.prefix,
+ };
+ sessionDataByWatcherActor.set(watcherActorID, sessionData);
+ watcherActors.set(watcherActorID, watcher);
+ }
+ return sessionData;
+ },
+
+ /**
+ * Given a Watcher Actor ID, return the related Watcher Actor instance.
+ *
+ * @param String actorID
+ * The Watcher Actor ID to search for.
+ * @return WatcherActor
+ * The Watcher Actor instance.
+ */
+ getWatcher(actorID) {
+ return watcherActors.get(actorID);
+ },
+
+ /**
+ * Return an array of the watcher actors that match the passed browserId
+ *
+ * @param {Number} browserId
+ * @returns {Array<WatcherActor>} An array of the matching watcher actors
+ */
+ getWatchersForBrowserId(browserId) {
+ const watchers = [];
+ for (const watcherActor of watcherActors.values()) {
+ if (
+ watcherActor.sessionContext.type == "browser-element" &&
+ watcherActor.sessionContext.browserId === browserId
+ ) {
+ watchers.push(watcherActor);
+ }
+ }
+
+ return watchers;
+ },
+
+ /**
+ * Notify that a given watcher added or set some entries for given data type.
+ *
+ * @param WatcherActor watcher
+ * The WatcherActor which starts observing.
+ * @param string type
+ * The type of data to be added
+ * @param Array<Object> entries
+ * The values to be added to this type of data
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+ addOrSetSessionDataEntry(watcher, type, entries, updateType) {
+ const sessionData = this.getSessionData(watcher, {
+ createData: true,
+ });
+
+ if (!SUPPORTED_DATA_TYPES.includes(type)) {
+ throw new Error(`Unsupported session data type: ${type}`);
+ }
+
+ SessionDataHelpers.addOrSetSessionDataEntry(
+ sessionData,
+ type,
+ entries,
+ updateType
+ );
+
+ // Register the JS Window Actor the first time we start watching for something (e.g. resource, target, …).
+ registerJSWindowActor();
+
+ persistMapToSharedData();
+ },
+
+ /**
+ * Notify that a given watcher removed an entry in a given data type.
+ *
+ * @param WatcherActor watcher
+ * The WatcherActor which stops observing.
+ * @param string type
+ * The type of data to be removed
+ * @param Array<Object> entries
+ * The values to be removed to this type of data
+ * @params {Object} options
+ * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the
+ * result of a change to the devtools.browsertoolbox.scope pref.
+ *
+ * @return boolean
+ * True if we such entry was already registered, for this watcher actor.
+ */
+ removeSessionDataEntry(watcher, type, entries, options) {
+ const sessionData = this.getSessionData(watcher);
+ if (!sessionData) {
+ return false;
+ }
+
+ if (!SUPPORTED_DATA_TYPES.includes(type)) {
+ throw new Error(`Unsupported session data type: ${type}`);
+ }
+
+ if (
+ !SessionDataHelpers.removeSessionDataEntry(sessionData, type, entries)
+ ) {
+ return false;
+ }
+
+ const isWatchingSomething = SUPPORTED_DATA_TYPES.some(
+ dataType => sessionData[dataType] && !!sessionData[dataType].length
+ );
+
+ // Remove the watcher reference if it's not watching for anything anymore, unless we're
+ // doing a mode switch; in such case we don't mean to end the DevTools session, so we
+ // still want to have access to the underlying data (furthermore, such case should only
+ // happen in tests, in a regular workflow we'd still be watching for resources).
+ if (!isWatchingSomething && !options?.isModeSwitching) {
+ sessionDataByWatcherActor.delete(watcher.actorID);
+ watcherActors.delete(watcher.actorID);
+ }
+
+ persistMapToSharedData();
+
+ return true;
+ },
+
+ /**
+ * Cleanup everything about a given watcher actor.
+ * Remove it from any registry so that we stop interacting with it.
+ *
+ * The watcher would be automatically unregistered from removeWatcherEntry,
+ * if we remove all entries. But we aren't removing all breakpoints.
+ * So here, we force clearing any reference to the watcher actor when it destroys.
+ */
+ unregisterWatcher(watcher) {
+ sessionDataByWatcherActor.delete(watcher.actorID);
+ watcherActors.delete(watcher.actorID);
+ this.maybeUnregisteringJSWindowActor();
+ },
+
+ /**
+ * Notify that a given watcher starts observing a new target type.
+ *
+ * @param WatcherActor watcher
+ * The WatcherActor which starts observing.
+ * @param string targetType
+ * The new target type to start listening to.
+ */
+ watchTargets(watcher, targetType) {
+ this.addOrSetSessionDataEntry(
+ watcher,
+ SUPPORTED_DATA.TARGETS,
+ [targetType],
+ "add"
+ );
+ },
+
+ /**
+ * Notify that a given watcher stops observing a given target type.
+ *
+ * @param WatcherActor watcher
+ * The WatcherActor which stops observing.
+ * @param string targetType
+ * The new target type to stop listening to.
+ * @params {Object} options
+ * @params {Boolean} options.isModeSwitching: Set to true true when this is called as the
+ * result of a change to the devtools.browsertoolbox.scope pref.
+ * @return boolean
+ * True if we were watching for this target type, for this watcher actor.
+ */
+ unwatchTargets(watcher, targetType, options) {
+ return this.removeSessionDataEntry(
+ watcher,
+ SUPPORTED_DATA.TARGETS,
+ [targetType],
+ options
+ );
+ },
+
+ /**
+ * Notify that a given watcher starts observing new resource types.
+ *
+ * @param WatcherActor watcher
+ * The WatcherActor which starts observing.
+ * @param Array<string> resourceTypes
+ * The new resource types to start listening to.
+ */
+ watchResources(watcher, resourceTypes) {
+ this.addOrSetSessionDataEntry(
+ watcher,
+ SUPPORTED_DATA.RESOURCES,
+ resourceTypes,
+ "add"
+ );
+ },
+
+ /**
+ * Notify that a given watcher stops observing given resource types.
+ *
+ * See `watchResources` for argument definition.
+ *
+ * @return boolean
+ * True if we were watching for this resource type, for this watcher actor.
+ */
+ unwatchResources(watcher, resourceTypes) {
+ return this.removeSessionDataEntry(
+ watcher,
+ SUPPORTED_DATA.RESOURCES,
+ resourceTypes
+ );
+ },
+
+ /**
+ * Unregister the JS Window Actor if there is no more DevTools code observing any target/resource.
+ */
+ maybeUnregisteringJSWindowActor() {
+ if (sessionDataByWatcherActor.size == 0) {
+ unregisterJSWindowActor();
+ }
+ },
+};
+
+// Boolean flag to know if the DevToolsFrame JS Window Actor is currently registered
+let isJSWindowActorRegistered = false;
+
+/**
+ * Register the JSWindowActor pair "DevToolsFrame".
+ *
+ * We should call this method before we try to use this JS Window Actor from the parent process
+ * (via `WindowGlobal.getActor("DevToolsFrame")` or `WindowGlobal.getActor("DevToolsWorker")`).
+ * Also, registering it will automatically force spawing the content process JSWindow Actor
+ * anytime a new document is opened (via DOMWindowCreated event).
+ */
+
+const JSWindowActorsConfig = {
+ DevToolsFrame: {
+ parent: {
+ esModuleURI:
+ "resource://devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "resource://devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs",
+ events: {
+ DOMWindowCreated: {},
+ DOMDocElementInserted: {},
+ pageshow: {},
+ pagehide: {},
+ },
+ },
+ allFrames: true,
+ },
+ DevToolsWorker: {
+ parent: {
+ esModuleURI:
+ "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "resource://devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs",
+ events: {
+ DOMWindowCreated: {},
+ },
+ },
+ allFrames: true,
+ },
+};
+
+function registerJSWindowActor() {
+ if (isJSWindowActorRegistered) {
+ return;
+ }
+ isJSWindowActorRegistered = true;
+ ActorManagerParent.addJSWindowActors(JSWindowActorsConfig);
+}
+
+function unregisterJSWindowActor() {
+ if (!isJSWindowActorRegistered) {
+ return;
+ }
+ isJSWindowActorRegistered = false;
+
+ for (const JSWindowActorName of Object.keys(JSWindowActorsConfig)) {
+ // ActorManagerParent doesn't expose a "removeActors" method, but it would be equivalent to that:
+ ChromeUtils.unregisterWindowActor(JSWindowActorName);
+ }
+}
diff --git a/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs b/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs
new file mode 100644
index 0000000000..d52cbc5708
--- /dev/null
+++ b/devtools/server/actors/watcher/browsing-context-helpers.sys.mjs
@@ -0,0 +1,428 @@
+/* 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/. */
+
+const isEveryFrameTargetEnabled = Services.prefs.getBoolPref(
+ "devtools.every-frame-target.enabled",
+ false
+);
+
+const WEBEXTENSION_FALLBACK_DOC_URL =
+ "chrome://devtools/content/shared/webextension-fallback.html";
+
+/**
+ * Retrieve the addon id corresponding to a given window global.
+ * This is usually extracted from the principal, but in case we are dealing
+ * with a DevTools webextension fallback window, the addon id will be available
+ * in the URL.
+ *
+ * @param {WindowGlobalChild|WindowGlobalParent} windowGlobal
+ * The WindowGlobal from which we want to extract the addonId. Either a
+ * WindowGlobalParent or a WindowGlobalChild depending on where this
+ * helper is used from.
+ * @return {String} Returns the addon id if any could found, null otherwise.
+ */
+export function getAddonIdForWindowGlobal(windowGlobal) {
+ const browsingContext = windowGlobal.browsingContext;
+ const isParent = CanonicalBrowsingContext.isInstance(browsingContext);
+ // documentPrincipal is only exposed on WindowGlobalParent,
+ // use a fallback for WindowGlobalChild.
+ const principal = isParent
+ ? windowGlobal.documentPrincipal
+ : browsingContext.window.document.nodePrincipal;
+
+ // On Android we can get parent process windows where `documentPrincipal` and
+ // `documentURI` are both unavailable. Bail out early.
+ if (!principal) {
+ return null;
+ }
+
+ // Most webextension documents are loaded from moz-extension://{addonId} and
+ // the principal provides the addon id.
+ if (principal.addonId) {
+ return principal.addonId;
+ }
+
+ // If no addon id was available on the principal, check if the window is the
+ // DevTools fallback window and extract the addon id from the URL.
+ const href = isParent
+ ? windowGlobal.documentURI?.displaySpec
+ : browsingContext.window.document.location.href;
+
+ if (href && href.startsWith(WEBEXTENSION_FALLBACK_DOC_URL)) {
+ const [, addonId] = href.split("#");
+ return addonId;
+ }
+
+ return null;
+}
+
+/**
+ * Helper function to know if a given BrowsingContext should be debugged by scope
+ * described by the given session context.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context we want to check if it is part of debugged context
+ * @param {Object} sessionContext
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * @param {Object} options
+ * Optional arguments passed via a dictionary.
+ * @param {Boolean} options.forceAcceptTopLevelTarget
+ * If true, we will accept top level browsing context even when server target switching
+ * is disabled. In case of client side target switching, the top browsing context
+ * is debugged via a target actor that is being instantiated manually by the frontend.
+ * And this target actor isn't created, nor managed by the watcher actor.
+ * @param {Boolean} options.acceptInitialDocument
+ * By default, we ignore initial about:blank documents/WindowGlobals.
+ * But some code cares about all the WindowGlobals, this flag allows to also accept them.
+ * (Used by _validateWindowGlobal)
+ * @param {Boolean} options.acceptSameProcessIframes
+ * If true, we will accept WindowGlobal that runs in the same process as their parent document.
+ * That, even when EFT is disabled.
+ * (Used by _validateWindowGlobal)
+ * @param {Boolean} options.acceptNoWindowGlobal
+ * By default, we will reject BrowsingContext that don't have any WindowGlobal,
+ * either retrieved via BrowsingContext.currentWindowGlobal in the parent process,
+ * or via the options.windowGlobal argument.
+ * But in some case, we are processing BrowsingContext very early, before any
+ * WindowGlobal has been created for it. But they are still relevant BrowsingContexts
+ * to debug.
+ * @param {WindowGlobal} options.windowGlobal
+ * When we are in the content process, we can't easily retrieve the WindowGlobal
+ * for a given BrowsingContext. So allow to pass it via this argument.
+ * Also, there is some race conditions where browsingContext.currentWindowGlobal
+ * is null, while the callsite may have a reference to the WindowGlobal.
+ */
+// The goal of this method is to gather all checks done against BrowsingContext and WindowGlobal interfaces
+// which leads it to be a lengthy method. So disable the complexity rule which is counter productive here.
+// eslint-disable-next-line complexity
+export function isBrowsingContextPartOfContext(
+ browsingContext,
+ sessionContext,
+ options = {}
+) {
+ let {
+ forceAcceptTopLevelTarget = false,
+ acceptNoWindowGlobal = false,
+ windowGlobal,
+ } = options;
+
+ // For now, reject debugging chrome BrowsingContext.
+ // This is for example top level chrome windows like browser.xhtml or webconsole/index.html (only the browser console)
+ //
+ // Tab and WebExtension debugging shouldn't target any such privileged document.
+ // All their document should be of type "content".
+ //
+ // This may only be an issue for the Browser Toolbox.
+ // For now, we expect the ParentProcessTargetActor to debug these.
+ // Note that we should probably revisit that, and have each WindowGlobal be debugged
+ // by one dedicated WindowGlobalTargetActor (bug 1685500). This requires some tweaks, at least in console-message
+ // resource watcher, which makes the ParentProcessTarget's console message resource watcher watch
+ // for all documents messages. It should probably only care about window-less messages and have one target per window global,
+ // each target fetching one window global messages.
+ //
+ // Such project would be about applying "EFT" to the browser toolbox and non-content documents
+ if (
+ CanonicalBrowsingContext.isInstance(browsingContext) &&
+ !browsingContext.isContent
+ ) {
+ return false;
+ }
+
+ if (!windowGlobal) {
+ // When we are in the parent process, WindowGlobal can be retrieved from the BrowsingContext,
+ // while in the content process, the callsites have to pass it manually as an argument
+ if (CanonicalBrowsingContext.isInstance(browsingContext)) {
+ windowGlobal = browsingContext.currentWindowGlobal;
+ } else if (!windowGlobal && !acceptNoWindowGlobal) {
+ throw new Error(
+ "isBrowsingContextPartOfContext expect a windowGlobal argument when called from the content process"
+ );
+ }
+ }
+ // If we have a WindowGlobal, there is some additional checks we can do
+ if (
+ windowGlobal &&
+ !_validateWindowGlobal(windowGlobal, sessionContext, options)
+ ) {
+ return false;
+ }
+ // Loading or destroying BrowsingContext won't have any associated WindowGlobal.
+ // Ignore them by default. They should be either handled via DOMWindowCreated event or JSWindowActor destroy
+ if (!windowGlobal && !acceptNoWindowGlobal) {
+ return false;
+ }
+
+ // Now do the checks specific to each session context type
+ if (sessionContext.type == "all") {
+ return true;
+ }
+ if (sessionContext.type == "browser-element") {
+ // Check if the document is:
+ // - part of the Browser element, or,
+ // - a popup originating from the browser element (the popup being loaded in a distinct browser element)
+ const isMatchingTheBrowserElement =
+ browsingContext.browserId == sessionContext.browserId;
+ if (
+ !isMatchingTheBrowserElement &&
+ !isPopupToDebug(browsingContext, sessionContext)
+ ) {
+ return false;
+ }
+
+ // For client-side target switching, only mention the "remote frames".
+ // i.e. the frames which are in a distinct process compared to their parent document
+ // If there is no parent, this is most likely the top level document which we want to ignore.
+ //
+ // `forceAcceptTopLevelTarget` is set:
+ // * when navigating to and from pages in the bfcache, we ignore client side target
+ // and start emitting top level target from the server.
+ // * when the callsite care about all the debugged browsing contexts,
+ // no matter if their related targets are created by client or server.
+ const isClientSideTargetSwitching =
+ !sessionContext.isServerTargetSwitchingEnabled;
+ const isTopLevelBrowsingContext = !browsingContext.parent;
+ if (
+ isClientSideTargetSwitching &&
+ !forceAcceptTopLevelTarget &&
+ isTopLevelBrowsingContext
+ ) {
+ return false;
+ }
+ return true;
+ }
+
+ if (sessionContext.type == "webextension") {
+ // Next and last check expects a WindowGlobal.
+ // As we have no way to really know if this BrowsingContext is related to this add-on,
+ // ignore it. Even if callsite accepts browsing context without a window global.
+ if (!windowGlobal) {
+ return false;
+ }
+
+ return getAddonIdForWindowGlobal(windowGlobal) == sessionContext.addonId;
+ }
+ throw new Error("Unsupported session context type: " + sessionContext.type);
+}
+
+/**
+ * Return true for popups to debug when debugging a browser-element.
+ *
+ * @param {BrowsingContext} browsingContext
+ * The browsing context we want to check if it is part of debugged context
+ * @param {Object} sessionContext
+ * WatcherActor's session context. This helps know what is the overall debugged scope.
+ * See watcher actor constructor for more info.
+ */
+function isPopupToDebug(browsingContext, sessionContext) {
+ // If enabled, create targets for popups (i.e. window.open() calls).
+ // If the opener is the tab we are currently debugging, accept the WindowGlobal and create a target for it.
+ //
+ // Note that it is important to do this check *after* the isInitialDocument one.
+ // Popups end up involving three WindowGlobals:
+ // - a first WindowGlobal loading an initial about:blank document (so isInitialDocument is true)
+ // - a second WindowGlobal which looks exactly as the first one
+ // - a final WindowGlobal which loads the URL passed to window.open() (so isInitialDocument is false)
+ //
+ // For now, we only instantiate a target for the last WindowGlobal.
+ return (
+ sessionContext.isPopupDebuggingEnabled &&
+ browsingContext.opener &&
+ browsingContext.opener.browserId == sessionContext.browserId
+ );
+}
+
+/**
+ * Helper function of isBrowsingContextPartOfContext to execute all checks
+ * against WindowGlobal interface which aren't specific to a given SessionContext type
+ *
+ * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal
+ * The WindowGlobal we want to check if it is part of debugged context
+ * @param {Object} sessionContext
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * @param {Object} options
+ * Optional arguments passed via a dictionary.
+ * See `isBrowsingContextPartOfContext` jsdoc.
+ */
+function _validateWindowGlobal(
+ windowGlobal,
+ sessionContext,
+ { acceptInitialDocument, acceptSameProcessIframes }
+) {
+ // By default, before loading the actual document (even an about:blank document),
+ // we do load immediately "the initial about:blank document".
+ // This is expected by the spec. Typically when creating a new BrowsingContext/DocShell/iframe,
+ // we would have such transient initial document.
+ // `Document.isInitialDocument` helps identify this transient document, which
+ // we want to ignore as it would instantiate a very short lived target which
+ // confuses many tests and triggers race conditions by spamming many targets.
+ //
+ // We also ignore some other transient empty documents created while using `window.open()`
+ // When using this API with cross process loads, we may create up to three documents/WindowGlobals.
+ // We get a first initial about:blank document, and a second document created
+ // for moving the document in the right principal.
+ // The third document will be the actual document we expect to debug.
+ // The second document is an implementation artifact which ideally wouldn't exist
+ // and isn't expected by the spec.
+ // Note that `window.print` and print preview are using `window.open` and are going through this.
+ //
+ // WindowGlobalParent will have `isInitialDocument` attribute, while we have to go through the Document for WindowGlobalChild.
+ const isInitialDocument =
+ windowGlobal.isInitialDocument ||
+ windowGlobal.browsingContext.window?.document.isInitialDocument;
+ if (isInitialDocument && !acceptInitialDocument) {
+ return false;
+ }
+
+ // We may process an iframe that runs in the same process as its parent and we don't want
+ // to create targets for them if same origin targets (=EFT) are not enabled.
+ // Instead the WindowGlobalTargetActor will inspect these children document via docShell tree
+ // (typically via `docShells` or `windows` getters).
+ // This is quite common when Fission is off as any iframe will run in same process
+ // as their parent document. But it can also happen with Fission enabled if iframes have
+ // children iframes using the same origin.
+ const isSameProcessIframe = !windowGlobal.isProcessRoot;
+ if (
+ isSameProcessIframe &&
+ !acceptSameProcessIframes &&
+ !isEveryFrameTargetEnabled
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * Helper function to know if a given WindowGlobal should be debugged by scope
+ * described by the given session context. This method could be called from any process
+ * as so accept either WindowGlobalParent or WindowGlobalChild instances.
+ *
+ * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal
+ * The WindowGlobal we want to check if it is part of debugged context
+ * @param {Object} sessionContext
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * @param {Object} options
+ * Optional arguments passed via a dictionary.
+ * See `isBrowsingContextPartOfContext` jsdoc.
+ */
+export function isWindowGlobalPartOfContext(
+ windowGlobal,
+ sessionContext,
+ options
+) {
+ return isBrowsingContextPartOfContext(
+ windowGlobal.browsingContext,
+ sessionContext,
+ {
+ ...options,
+ windowGlobal,
+ }
+ );
+}
+
+/**
+ * Get all the BrowsingContexts that should be debugged by the given session context.
+ * Consider using WatcherActor.getAllBrowsingContexts(options) which will automatically pass the right sessionContext.
+ *
+ * Really all of them:
+ * - For all the privileged windows (browser.xhtml, browser console, ...)
+ * - For all chrome *and* content contexts (privileged windows, as well as <browser> elements and their inner content documents)
+ * - For all nested browsing context. We fetch the contexts recursively.
+ *
+ * @param {Object} sessionContext
+ * The Session Context to help know what is debugged.
+ * See devtools/server/actors/watcher/session-context.js
+ * @param {Object} options
+ * Optional arguments passed via a dictionary.
+ * @param {Boolean} options.acceptSameProcessIframes
+ * If true, we will accept WindowGlobal that runs in the same process as their parent document.
+ * That, even when EFT is disabled.
+ */
+export function getAllBrowsingContextsForContext(
+ sessionContext,
+ { acceptSameProcessIframes = false } = {}
+) {
+ const browsingContexts = [];
+
+ // For a given BrowsingContext, add the `browsingContext`
+ // all of its children, that, recursively.
+ function walk(browsingContext) {
+ if (browsingContexts.includes(browsingContext)) {
+ return;
+ }
+ browsingContexts.push(browsingContext);
+
+ for (const child of browsingContext.children) {
+ walk(child);
+ }
+
+ if (
+ (sessionContext.type == "all" || sessionContext.type == "webextension") &&
+ browsingContext.window
+ ) {
+ // If the document is in the parent process, also iterate over each <browser>'s browsing context.
+ // BrowsingContext.children doesn't cross chrome to content boundaries,
+ // so we have to cross these boundaries by ourself.
+ // (This is also the reason why we aren't using BrowsingContext.getAllBrowsingContextsInSubtree())
+ for (const browser of browsingContext.window.document.querySelectorAll(
+ `browser[type="content"]`
+ )) {
+ walk(browser.browsingContext);
+ }
+ }
+ }
+
+ // If target a single browser element, only walk through its BrowsingContext
+ if (sessionContext.type == "browser-element") {
+ const topBrowsingContext = BrowsingContext.getCurrentTopByBrowserId(
+ sessionContext.browserId
+ );
+ // topBrowsingContext can be null if getCurrentTopByBrowserId is called for a tab that is unloaded.
+ if (topBrowsingContext) {
+ // Unfortunately, getCurrentTopByBrowserId is subject to race conditions and may refer to a BrowsingContext
+ // that already navigated away.
+ // Query the current "live" BrowsingContext by going through the embedder element (i.e. the <browser>/<iframe> element)
+ // devtools/client/responsive/test/browser/browser_navigation.js covers this with fission enabled.
+ const realTopBrowsingContext =
+ topBrowsingContext.embedderElement.browsingContext;
+ walk(realTopBrowsingContext);
+ }
+ } else if (
+ sessionContext.type == "all" ||
+ sessionContext.type == "webextension"
+ ) {
+ // For the browser toolbox and web extension, retrieve all possible BrowsingContext.
+ // For WebExtension, we will then filter out the BrowsingContexts via `isBrowsingContextPartOfContext`.
+ //
+ // Fetch all top level window's browsing contexts
+ for (const window of Services.ww.getWindowEnumerator()) {
+ if (window.docShell.browsingContext) {
+ walk(window.docShell.browsingContext);
+ }
+ }
+ } else {
+ throw new Error("Unsupported session context type: " + sessionContext.type);
+ }
+
+ return browsingContexts.filter(bc =>
+ // We force accepting the top level browsing context, otherwise
+ // it would only be returned if sessionContext.isServerSideTargetSwitching is enabled.
+ isBrowsingContextPartOfContext(bc, sessionContext, {
+ forceAcceptTopLevelTarget: true,
+ acceptSameProcessIframes,
+ })
+ );
+}
+
+if (typeof module == "object") {
+ module.exports = {
+ isBrowsingContextPartOfContext,
+ isWindowGlobalPartOfContext,
+ getAddonIdForWindowGlobal,
+ getAllBrowsingContextsForContext,
+ };
+}
diff --git a/devtools/server/actors/watcher/moz.build b/devtools/server/actors/watcher/moz.build
new file mode 100644
index 0000000000..46a9d89718
--- /dev/null
+++ b/devtools/server/actors/watcher/moz.build
@@ -0,0 +1,16 @@
+# -*- 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 += [
+ "target-helpers",
+]
+
+DevToolsModules(
+ "browsing-context-helpers.sys.mjs",
+ "session-context.js",
+ "SessionDataHelpers.jsm",
+ "WatcherRegistry.sys.mjs",
+)
diff --git a/devtools/server/actors/watcher/session-context.js b/devtools/server/actors/watcher/session-context.js
new file mode 100644
index 0000000000..6457399455
--- /dev/null
+++ b/devtools/server/actors/watcher/session-context.js
@@ -0,0 +1,219 @@
+/* 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 to create all the Session Context objects.
+//
+// These are static JSON serializable object that help describe
+// the debugged context. It is passed around to most of the server codebase
+// in order to know which object to consider inspecting and communicating back to the client.
+//
+// These objects are all instantiated by the Descriptor actors
+// and passed as a constructor argument to the Watcher actor.
+//
+// These objects have attributes used by all the Session contexts:
+// - type: String
+// Describes which type of context we are debugging.
+// See SESSION_TYPES for all possible values.
+// See each create* method for more info about each type and their specific attributes.
+// - isServerTargetSwitchingEnabled: Boolean
+// If true, targets should all be spawned by the server codebase.
+// Especially the first, top level target.
+// - supportedTargets: Boolean
+// An object keyed by target type, whose value indicates if we have watcher support
+// for the target.
+// - supportedResources: Boolean
+// An object keyed by resource type, whose value indicates if we have watcher support
+// for the resource.
+
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+
+const SESSION_TYPES = {
+ ALL: "all",
+ BROWSER_ELEMENT: "browser-element",
+ CONTENT_PROCESS: "content-process",
+ WEBEXTENSION: "webextension",
+ WORKER: "worker",
+};
+
+/**
+ * Create the SessionContext used by the Browser Toolbox and Browser Console.
+ *
+ * This context means debugging everything.
+ * The whole browser:
+ * - all processes: parent and content,
+ * - all privileges: privileged/chrome and content/web,
+ * - all components/targets: HTML documents, processes, workers, add-ons,...
+ */
+function createBrowserSessionContext() {
+ const type = SESSION_TYPES.ALL;
+
+ return {
+ type,
+ // For now, the top level target (ParentProcessTargetActor) is created via ProcessDescriptor.getTarget
+ // and is never replaced by any other, nor is it created by the WatcherActor.
+ isServerTargetSwitchingEnabled: false,
+ supportedTargets: getWatcherSupportedTargets(type),
+ supportedResources: getWatcherSupportedResources(type),
+ };
+}
+
+/**
+ * Create the SessionContext used by the regular web page toolboxes as well as remote debugging android device tabs.
+ *
+ * @param {BrowserElement} browserElement
+ * The tab to debug. It should be a reference to a <browser> element.
+ * @param {Object} config
+ * An object with optional configuration. Only supports "isServerTargetSwitchingEnabled" attribute.
+ * See jsdoc in this file header for more info.
+ */
+function createBrowserElementSessionContext(browserElement, config) {
+ const type = SESSION_TYPES.BROWSER_ELEMENT;
+ return {
+ type,
+ browserId: browserElement.browserId,
+ // Nowaday, it should always be enabled except for WebExtension special
+ // codepath and some tests.
+ isServerTargetSwitchingEnabled: config.isServerTargetSwitchingEnabled,
+ // Should we instantiate targets for popups opened in distinct tabs/windows?
+ // Driven by devtools.popups.debug=true preference.
+ isPopupDebuggingEnabled: config.isPopupDebuggingEnabled,
+ supportedTargets: getWatcherSupportedTargets(type),
+ supportedResources: getWatcherSupportedResources(type),
+ };
+}
+
+/**
+ * Create the SessionContext used by the web extension toolboxes.
+ *
+ * @param {Object} addon
+ * First object argument to describe the add-on.
+ * @param {String} addon.addonId
+ * The web extension ID, to uniquely identify the debugged add-on.
+ * @param {String} addon.browsingContextID
+ * The ID of the BrowsingContext into which this add-on is loaded.
+ * For now the top level target is associated with this one precise BrowsingContext.
+ * Knowing about it later helps associate resources to the same BrowsingContext ID and so the same target.
+ * @param {String} addon.innerWindowId
+ * The ID of the WindowGlobal into which this add-on is loaded.
+ * This is used for the same reason as browsingContextID. It helps match the resource with the right target.
+ * We now also use the WindowGlobal ID/innerWindowId to identify the targets.
+ * @param {Object} config
+ * An object with optional configuration. Only supports "isServerTargetSwitchingEnabled" attribute.
+ * See jsdoc in this file header for more info.
+ */
+function createWebExtensionSessionContext(
+ { addonId, browsingContextID, innerWindowId },
+ config
+) {
+ const type = SESSION_TYPES.WEBEXTENSION;
+ return {
+ type,
+ addonId,
+ addonBrowsingContextID: browsingContextID,
+ addonInnerWindowId: innerWindowId,
+ // For now, there is only one target (WebExtensionTargetActor), it is never replaced,
+ // and is only created via WebExtensionDescriptor.getTarget (and never by the watcher actor).
+ isServerTargetSwitchingEnabled: config.isServerTargetSwitchingEnabled,
+ supportedTargets: getWatcherSupportedTargets(type),
+ supportedResources: getWatcherSupportedResources(type),
+ };
+}
+
+/**
+ * Create the SessionContext used by the Browser Content Toolbox, to debug only one content process.
+ * Or when debugging XpcShell via about:debugging, where we instantiate only one content process target.
+ */
+function createContentProcessSessionContext() {
+ const type = SESSION_TYPES.CONTENT_PROCESS;
+ return {
+ type,
+ supportedTargets: getWatcherSupportedTargets(type),
+ supportedResources: getWatcherSupportedResources(type),
+ };
+}
+
+/**
+ * Create the SessionContext used when debugging one specific Service Worker or special chrome worker.
+ * This is only used from about:debugging.
+ */
+function createWorkerSessionContext() {
+ const type = SESSION_TYPES.WORKER;
+ return {
+ type,
+ supportedTargets: getWatcherSupportedTargets(type),
+ supportedResources: getWatcherSupportedResources(type),
+ };
+}
+
+/**
+ * Get the supported targets by the watcher given a session context type.
+ *
+ * @param {String} type
+ * @returns {Object}
+ */
+function getWatcherSupportedTargets(type) {
+ return {
+ [Targets.TYPES.FRAME]: true,
+ [Targets.TYPES.PROCESS]: true,
+ [Targets.TYPES.WORKER]:
+ type == SESSION_TYPES.BROWSER_ELEMENT ||
+ type == SESSION_TYPES.WEBEXTENSION,
+ [Targets.TYPES.SERVICE_WORKER]: type == SESSION_TYPES.BROWSER_ELEMENT,
+ };
+}
+
+/**
+ * Get the supported resources by the watcher given a session context type.
+ *
+ * @param {String} type
+ * @returns {Object}
+ */
+function getWatcherSupportedResources(type) {
+ // All resources types are supported for tab debugging and web extensions.
+ // Some watcher classes are still disabled for the Multiprocess Browser Toolbox (type=SESSION_TYPES.ALL).
+ // And they may also be disabled for workers once we start supporting them by the watcher.
+ // So set the traits to false for all the resources that we don't support yet
+ // and keep using the legacy listeners.
+ const isTabOrWebExtensionToolbox =
+ type == SESSION_TYPES.BROWSER_ELEMENT || type == SESSION_TYPES.WEBEXTENSION;
+
+ return {
+ [Resources.TYPES.CONSOLE_MESSAGE]: true,
+ [Resources.TYPES.CSS_CHANGE]: isTabOrWebExtensionToolbox,
+ [Resources.TYPES.CSS_MESSAGE]: true,
+ [Resources.TYPES.CSS_REGISTERED_PROPERTIES]: true,
+ [Resources.TYPES.DOCUMENT_EVENT]: true,
+ [Resources.TYPES.CACHE_STORAGE]: true,
+ [Resources.TYPES.COOKIE]: true,
+ [Resources.TYPES.ERROR_MESSAGE]: true,
+ [Resources.TYPES.EXTENSION_STORAGE]: true,
+ [Resources.TYPES.INDEXED_DB]: true,
+ [Resources.TYPES.LOCAL_STORAGE]: true,
+ [Resources.TYPES.SESSION_STORAGE]: true,
+ [Resources.TYPES.PLATFORM_MESSAGE]: true,
+ [Resources.TYPES.NETWORK_EVENT]: true,
+ [Resources.TYPES.NETWORK_EVENT_STACKTRACE]: true,
+ [Resources.TYPES.REFLOW]: true,
+ [Resources.TYPES.STYLESHEET]: true,
+ [Resources.TYPES.SOURCE]: true,
+ [Resources.TYPES.THREAD_STATE]: true,
+ [Resources.TYPES.SERVER_SENT_EVENT]: true,
+ [Resources.TYPES.WEBSOCKET]: true,
+ [Resources.TYPES.JSTRACER_TRACE]: true,
+ [Resources.TYPES.JSTRACER_STATE]: true,
+ [Resources.TYPES.LAST_PRIVATE_CONTEXT_EXIT]: true,
+ };
+}
+
+module.exports = {
+ createBrowserSessionContext,
+ createBrowserElementSessionContext,
+ createWebExtensionSessionContext,
+ createContentProcessSessionContext,
+ createWorkerSessionContext,
+ SESSION_TYPES,
+};
diff --git a/devtools/server/actors/watcher/target-helpers/frame-helper.js b/devtools/server/actors/watcher/target-helpers/frame-helper.js
new file mode 100644
index 0000000000..0e6f4f80d3
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/frame-helper.js
@@ -0,0 +1,331 @@
+/* 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 { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+const { WindowGlobalLogger } = ChromeUtils.importESModule(
+ "resource://devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs"
+);
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+const browsingContextAttachedObserverByWatcher = new Map();
+
+/**
+ * Force creating targets for all existing BrowsingContext, that, for a given Watcher Actor.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to watch for new targets.
+ */
+async function createTargets(watcher) {
+ // Go over all existing BrowsingContext in order to:
+ // - Force the instantiation of a DevToolsFrameChild
+ // - Have the DevToolsFrameChild to spawn the WindowGlobalTargetActor
+
+ // If we have a browserElement, set the watchedByDevTools flag on its related browsing context
+ // TODO: We should also set the flag for the "parent process" browsing context when we're
+ // in the browser toolbox. This is blocked by Bug 1675763, and should be handled as part
+ // of Bug 1709529.
+ if (watcher.sessionContext.type == "browser-element") {
+ // The `watchedByDevTools` enables gecko behavior tied to this flag, such as:
+ // - reporting the contents of HTML loaded in the docshells
+ // - capturing stacks for the network monitor.
+ watcher.browserElement.browsingContext.watchedByDevTools = true;
+ }
+
+ if (!browsingContextAttachedObserverByWatcher.has(watcher)) {
+ // We store the browserId here as watcher.browserElement.browserId can momentary be
+ // set to 0 when there's a navigation to a new browsing context.
+ const browserId = watcher.sessionContext.browserId;
+ const onBrowsingContextAttached = browsingContext => {
+ // We want to set watchedByDevTools on new top-level browsing contexts:
+ // - in the case of the BrowserToolbox/BrowserConsole, that would be the browsing
+ // contexts of all the tabs we want to handle.
+ // - for the regular toolbox, browsing context that are being created when navigating
+ // to a page that forces a new browsing context.
+ // Then BrowsingContext will propagate to all the tree of children BrowsingContext's.
+ if (
+ !browsingContext.parent &&
+ (watcher.sessionContext.type != "browser-element" ||
+ browserId === browsingContext.browserId)
+ ) {
+ browsingContext.watchedByDevTools = true;
+ }
+ };
+ Services.obs.addObserver(
+ onBrowsingContextAttached,
+ "browsing-context-attached"
+ );
+ // We store the observer so we can retrieve it elsewhere (e.g. for removal in destroyTargets).
+ browsingContextAttachedObserverByWatcher.set(
+ watcher,
+ onBrowsingContextAttached
+ );
+ }
+
+ if (
+ watcher.sessionContext.isServerTargetSwitchingEnabled &&
+ watcher.sessionContext.type == "browser-element"
+ ) {
+ // If server side target switching is enabled, process the top level browsing context first,
+ // so that we guarantee it is notified to the client first.
+ // If it is disabled, the top level target will be created from the client instead.
+ await createTargetForBrowsingContext({
+ watcher,
+ browsingContext: watcher.browserElement.browsingContext,
+ retryOnAbortError: true,
+ });
+ }
+
+ const browsingContexts = watcher.getAllBrowsingContexts().filter(
+ // Filter out the top browsing context we just processed.
+ browsingContext =>
+ browsingContext != watcher.browserElement?.browsingContext
+ );
+ // Await for the all the queries in order to resolve only *after* we received all
+ // already available targets.
+ // i.e. each call to `createTargetForBrowsingContext` should end up emitting
+ // a target-available-form event via the WatcherActor.
+ await Promise.allSettled(
+ browsingContexts.map(browsingContext =>
+ createTargetForBrowsingContext({ watcher, browsingContext })
+ )
+ );
+}
+
+/**
+ * (internal helper method) Force creating the target actor for a given BrowsingContext.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to watch for new targets.
+ * @param BrowsingContext browsingContext
+ * The context for which a target should be created.
+ * @param Boolean retryOnAbortError
+ * Set to true to retry creating existing targets when receiving an AbortError.
+ * An AbortError is sent when the JSWindowActor pair was destroyed before the query
+ * was complete, which can happen if the document navigates while the query is pending.
+ */
+async function createTargetForBrowsingContext({
+ watcher,
+ browsingContext,
+ retryOnAbortError = false,
+}) {
+ logWindowGlobal(browsingContext.currentWindowGlobal, "Existing WindowGlobal");
+
+ // We need to set the watchedByDevTools flag on all top-level browsing context. In the
+ // case of a content toolbox, this is done in the tab descriptor, but when we're in the
+ // browser toolbox, such descriptor is not created.
+ // Then BrowsingContext will propagate to all the tree of children BbrowsingContext's.
+ if (!browsingContext.parent) {
+ browsingContext.watchedByDevTools = true;
+ }
+
+ try {
+ await browsingContext.currentWindowGlobal
+ .getActor("DevToolsFrame")
+ .instantiateTarget({
+ watcherActorID: watcher.actorID,
+ connectionPrefix: watcher.conn.prefix,
+ sessionContext: watcher.sessionContext,
+ sessionData: watcher.sessionData,
+ });
+ } catch (e) {
+ console.warn(
+ "Failed to create DevTools Frame target for browsingContext",
+ browsingContext.id,
+ ": ",
+ e,
+ retryOnAbortError ? "retrying" : ""
+ );
+ if (retryOnAbortError && e.name === "AbortError") {
+ await createTargetForBrowsingContext({
+ watcher,
+ browsingContext,
+ retryOnAbortError,
+ });
+ } else {
+ throw e;
+ }
+ }
+}
+
+/**
+ * Force destroying all BrowsingContext targets which were related to a given watcher.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to stop watching for new targets.
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+function destroyTargets(watcher, options) {
+ // Go over all existing BrowsingContext in order to destroy all targets
+ const browsingContexts = watcher.getAllBrowsingContexts();
+
+ for (const browsingContext of browsingContexts) {
+ logWindowGlobal(
+ browsingContext.currentWindowGlobal,
+ "Existing WindowGlobal"
+ );
+
+ if (!browsingContext.parent) {
+ browsingContext.watchedByDevTools = false;
+ }
+
+ browsingContext.currentWindowGlobal
+ .getActor("DevToolsFrame")
+ .destroyTarget({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ options,
+ });
+ }
+
+ if (watcher.sessionContext.type == "browser-element") {
+ watcher.browserElement.browsingContext.watchedByDevTools = false;
+ }
+
+ if (browsingContextAttachedObserverByWatcher.has(watcher)) {
+ Services.obs.removeObserver(
+ browsingContextAttachedObserverByWatcher.get(watcher),
+ "browsing-context-attached"
+ );
+ browsingContextAttachedObserverByWatcher.delete(watcher);
+ }
+}
+
+/**
+ * Go over all existing BrowsingContext in order to communicate about new data entries
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to stop watching for new targets.
+ * @param string type
+ * The type of data to be added
+ * @param Array<Object> entries
+ * The values to be added to this type of data
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ const browsingContexts = getWatchingBrowsingContexts(watcher);
+ const promises = [];
+ for (const browsingContext of browsingContexts) {
+ logWindowGlobal(
+ browsingContext.currentWindowGlobal,
+ "Existing WindowGlobal"
+ );
+
+ const promise = browsingContext.currentWindowGlobal
+ .getActor("DevToolsFrame")
+ .addOrSetSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ promises.push(promise);
+ }
+ // Await for the queries in order to try to resolve only *after* the remote code processed the new data
+ return Promise.all(promises);
+}
+
+/**
+ * Notify all existing frame targets that some data entries have been removed
+ *
+ * See addOrSetSessionDataEntry for argument documentation.
+ */
+function removeSessionDataEntry({ watcher, type, entries }) {
+ const browsingContexts = getWatchingBrowsingContexts(watcher);
+ for (const browsingContext of browsingContexts) {
+ logWindowGlobal(
+ browsingContext.currentWindowGlobal,
+ "Existing WindowGlobal"
+ );
+
+ browsingContext.currentWindowGlobal
+ .getActor("DevToolsFrame")
+ .removeSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ });
+ }
+}
+
+module.exports = {
+ createTargets,
+ destroyTargets,
+ addOrSetSessionDataEntry,
+ removeSessionDataEntry,
+};
+
+/**
+ * Return the list of BrowsingContexts which should be targeted in order to communicate
+ * updated session data.
+ *
+ * @param WatcherActor watcher
+ * The watcher actor will be used to know which target we debug
+ * and what BrowsingContext should be considered.
+ */
+function getWatchingBrowsingContexts(watcher) {
+ // If we are watching for additional frame targets, it means that the multiprocess or fission mode is enabled,
+ // either for a content toolbox or a BrowserToolbox via scope set to everything.
+ const watchingAdditionalTargets = WatcherRegistry.isWatchingTargets(
+ watcher,
+ Targets.TYPES.FRAME
+ );
+ if (watchingAdditionalTargets) {
+ return watcher.getAllBrowsingContexts();
+ }
+ // By default, when we are no longer watching for frame targets, we should no longer try to
+ // communicate with any browsing-context. But.
+ //
+ // For "browser-element" debugging, all targets are provided by watching by watching for frame targets.
+ // So, when we are no longer watching for frame, we don't expect to have any frame target to talk to.
+ // => we should no longer reach any browsing context.
+ //
+ // For "all" (=browser toolbox), there is only the special ParentProcessTargetActor we might want to return here.
+ // But this is actually handled by the WatcherActor which uses `WatcherActor.getTargetActorInParentProcess` to convey session data.
+ // => we should no longer reach any browsing context.
+ //
+ // For "webextension" debugging, there is the special WebExtensionTargetActor, which doesn't run in the parent process,
+ // so that we can't rely on the same code as the browser toolbox.
+ // => we should always reach out this particular browsing context.
+ if (watcher.sessionContext.type == "webextension") {
+ const browsingContext = BrowsingContext.get(
+ watcher.sessionContext.addonBrowsingContextID
+ );
+ // The add-on browsing context may be destroying, in which case we shouldn't try to communicate with it
+ if (browsingContext.currentWindowGlobal) {
+ return [browsingContext];
+ }
+ }
+ return [];
+}
+
+// Set to true to log info about about WindowGlobal's being watched.
+const DEBUG = false;
+
+function logWindowGlobal(windowGlobal, message) {
+ if (!DEBUG) {
+ return;
+ }
+
+ WindowGlobalLogger.logWindowGlobal(windowGlobal, message);
+}
diff --git a/devtools/server/actors/watcher/target-helpers/moz.build b/devtools/server/actors/watcher/target-helpers/moz.build
new file mode 100644
index 0000000000..b7c8983590
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/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/.
+
+DevToolsModules(
+ "frame-helper.js",
+ "process-helper.js",
+ "service-worker-helper.js",
+ "service-worker-jsprocessactor-startup.js",
+ "worker-helper.js",
+)
diff --git a/devtools/server/actors/watcher/target-helpers/process-helper.js b/devtools/server/actors/watcher/target-helpers/process-helper.js
new file mode 100644
index 0000000000..8895d7ed66
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/process-helper.js
@@ -0,0 +1,389 @@
+/* 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 { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+
+loader.lazyRequireGetter(
+ this,
+ "ChildDebuggerTransport",
+ "resource://devtools/shared/transport/child-transport.js",
+ true
+);
+
+const CONTENT_PROCESS_SCRIPT =
+ "resource://devtools/server/startup/content-process-script.js";
+
+/**
+ * Map a MessageManager key to an Array of ContentProcessTargetActor "description" objects.
+ * A single MessageManager might be linked to several ContentProcessTargetActors if there are several
+ * Watcher actors instantiated on the DevToolsServer, via a single connection (in theory), but rather
+ * via distinct connections (ex: a content toolbox and the browser toolbox).
+ * Note that if we spawn two DevToolsServer, this module will be instantiated twice.
+ *
+ * Each ContentProcessTargetActor "description" object is structured as follows
+ * - {Object} actor: form of the content process target actor
+ * - {String} prefix: forwarding prefix used to redirect all packet to the right content process's transport
+ * - {ChildDebuggerTransport} childTransport: Transport forwarding all packets to the target's content process
+ * - {WatcherActor} watcher: The Watcher actor for which we instantiated this content process target actor
+ */
+const actors = new WeakMap();
+
+// Save the list of all watcher actors that are watching for processes
+const watchers = new Set();
+
+function onContentProcessActorCreated(msg) {
+ const { watcherActorID, prefix, actor } = msg.data;
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+ if (!watcher) {
+ throw new Error(
+ `Receiving a content process actor without a watcher actor ${watcherActorID}`
+ );
+ }
+ // Ignore watchers of other connections.
+ // We may have two browser toolbox connected to the same process.
+ // This will spawn two distinct Watcher actor and two distinct process target helper module.
+ // Avoid processing the event many times, otherwise we will notify about the same target
+ // multiple times.
+ if (!watchers.has(watcher)) {
+ return;
+ }
+ const messageManager = msg.target;
+ const connection = watcher.conn;
+
+ // Pipe Debugger message from/to parent/child via the message manager
+ const childTransport = new ChildDebuggerTransport(messageManager, prefix);
+ childTransport.hooks = {
+ onPacket: connection.send.bind(connection),
+ };
+ childTransport.ready();
+
+ connection.setForwarding(prefix, childTransport);
+
+ const list = actors.get(messageManager) || [];
+ list.push({
+ prefix,
+ childTransport,
+ actor,
+ watcher,
+ });
+ actors.set(messageManager, list);
+
+ watcher.notifyTargetAvailable(actor);
+}
+
+function onContentProcessActorDestroyed(msg) {
+ const { watcherActorID } = msg.data;
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+ if (!watcher) {
+ throw new Error(
+ `Receiving a content process actor destruction without a watcher actor ${watcherActorID}`
+ );
+ }
+ // Ignore watchers of other connections.
+ // We may have two browser toolbox connected to the same process.
+ // This will spawn two distinct Watcher actor and two distinct process target helper module.
+ // Avoid processing the event many times, otherwise we will notify about the same target
+ // multiple times.
+ if (!watchers.has(watcher)) {
+ return;
+ }
+ const messageManager = msg.target;
+ unregisterWatcherForMessageManager(watcher, messageManager);
+}
+
+function onMessageManagerClose(messageManager, topic, data) {
+ const list = actors.get(messageManager);
+ if (!list || !list.length) {
+ return;
+ }
+ for (const { prefix, childTransport, actor, watcher } of list) {
+ watcher.notifyTargetDestroyed(actor);
+
+ // If we have a child transport, the actor has already
+ // been created. We need to stop using this message manager.
+ childTransport.close();
+ watcher.conn.cancelForwarding(prefix);
+ }
+ actors.delete(messageManager);
+}
+
+/**
+ * Unregister everything created for a given watcher against a precise message manager:
+ * - clear up things from `actors` WeakMap,
+ * - notify all related target actors as being destroyed,
+ * - close all DevTools Transports being created for each Message Manager.
+ *
+ * @param {WatcherActor} watcher
+ * @param {MessageManager}
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+function unregisterWatcherForMessageManager(watcher, messageManager, options) {
+ const targetActorDescriptions = actors.get(messageManager);
+ if (!targetActorDescriptions || !targetActorDescriptions.length) {
+ return;
+ }
+
+ // Destroy all transports related to this watcher and tells the client to purge all related actors
+ const matchingTargetActorDescriptions = targetActorDescriptions.filter(
+ item => item.watcher === watcher
+ );
+ for (const {
+ prefix,
+ childTransport,
+ actor,
+ } of matchingTargetActorDescriptions) {
+ watcher.notifyTargetDestroyed(actor, options);
+
+ childTransport.close();
+ watcher.conn.cancelForwarding(prefix);
+ }
+
+ // Then update global `actors` WeakMap by stripping all data about this watcher
+ const remainingTargetActorDescriptions = targetActorDescriptions.filter(
+ item => item.watcher !== watcher
+ );
+ if (!remainingTargetActorDescriptions.length) {
+ actors.delete(messageManager);
+ } else {
+ actors.set(messageManager, remainingTargetActorDescriptions);
+ }
+}
+
+/**
+ * Destroy everything related to a given watcher that has been created in this module:
+ * (See unregisterWatcherForMessageManager)
+ *
+ * @param {WatcherActor} watcher
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+function closeWatcherTransports(watcher, options) {
+ for (let i = 0; i < Services.ppmm.childCount; i++) {
+ const messageManager = Services.ppmm.getChildAt(i);
+ unregisterWatcherForMessageManager(watcher, messageManager, options);
+ }
+}
+
+function maybeRegisterMessageListeners(watcher) {
+ const sizeBefore = watchers.size;
+ watchers.add(watcher);
+ if (sizeBefore == 0 && watchers.size == 1) {
+ Services.ppmm.addMessageListener(
+ "debug:content-process-actor",
+ onContentProcessActorCreated
+ );
+ Services.ppmm.addMessageListener(
+ "debug:content-process-actor-destroyed",
+ onContentProcessActorDestroyed
+ );
+ Services.obs.addObserver(onMessageManagerClose, "message-manager-close");
+
+ // Load the content process server startup script only once,
+ // otherwise it will be evaluated twice, listen to events twice and create
+ // target actors twice.
+ // We may try to load it twice when opening one Browser Toolbox via about:debugging
+ // and another regular Browser Toolbox. Both will spawn a WatcherActor and watch for processes.
+ const isContentProcessScripLoaded = Services.ppmm
+ .getDelayedProcessScripts()
+ .some(([uri]) => uri === CONTENT_PROCESS_SCRIPT);
+ if (!isContentProcessScripLoaded) {
+ Services.ppmm.loadProcessScript(CONTENT_PROCESS_SCRIPT, true);
+ }
+ }
+}
+
+/**
+ * @param {WatcherActor} watcher
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+function maybeUnregisterMessageListeners(watcher, options = {}) {
+ const sizeBefore = watchers.size;
+ watchers.delete(watcher);
+ closeWatcherTransports(watcher, options);
+
+ if (sizeBefore == 1 && watchers.size == 0) {
+ Services.ppmm.removeMessageListener(
+ "debug:content-process-actor",
+ onContentProcessActorCreated
+ );
+ Services.ppmm.removeMessageListener(
+ "debug:content-process-actor-destroyed",
+ onContentProcessActorDestroyed
+ );
+ Services.obs.removeObserver(onMessageManagerClose, "message-manager-close");
+
+ // We inconditionally remove the process script, while we should only remove it
+ // once the last DevToolsServer stop watching for processes.
+ // We might have many server, using distinct loaders, so that this module
+ // will be spawn many times and we should remove the script only once the last
+ // module unregister the last watcher of all.
+ Services.ppmm.removeDelayedProcessScript(CONTENT_PROCESS_SCRIPT);
+
+ Services.ppmm.broadcastAsyncMessage("debug:destroy-process-script", {
+ options,
+ });
+ }
+}
+
+async function createTargets(watcher) {
+ // XXX: Should this move to WatcherRegistry??
+ maybeRegisterMessageListeners(watcher);
+
+ // Bug 1648499: This could be simplified when migrating to JSProcessActor by using sendQuery.
+ // For now, hack into WatcherActor in order to know when we created one target
+ // actor for each existing content process.
+ // Also, we substract one as the parent process has a message manager and is counted
+ // in `childCount`, but we ignore it from the process script and it won't reply.
+ let contentProcessCount = Services.ppmm.childCount - 1;
+ if (contentProcessCount == 0) {
+ return;
+ }
+ const onTargetsCreated = new Promise(resolve => {
+ let receivedTargetCount = 0;
+ const listener = () => {
+ receivedTargetCount++;
+ mayBeResolve();
+ };
+ watcher.on("target-available-form", listener);
+ const onContentProcessClosed = () => {
+ // Update the content process count as one has been just destroyed
+ contentProcessCount--;
+ mayBeResolve();
+ };
+ Services.obs.addObserver(onContentProcessClosed, "message-manager-close");
+ function mayBeResolve() {
+ if (receivedTargetCount >= contentProcessCount) {
+ watcher.off("target-available-form", listener);
+ Services.obs.removeObserver(
+ onContentProcessClosed,
+ "message-manager-close"
+ );
+ resolve();
+ }
+ }
+ });
+
+ Services.ppmm.broadcastAsyncMessage("debug:instantiate-already-available", {
+ watcherActorID: watcher.actorID,
+ connectionPrefix: watcher.conn.prefix,
+ sessionData: watcher.sessionData,
+ });
+
+ await onTargetsCreated;
+}
+
+/**
+ * @param {WatcherActor} watcher
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+function destroyTargets(watcher, options) {
+ maybeUnregisterMessageListeners(watcher, options);
+
+ Services.ppmm.broadcastAsyncMessage("debug:destroy-target", {
+ watcherActorID: watcher.actorID,
+ });
+}
+
+/**
+ * Go over all existing content processes in order to communicate about new data entries
+ *
+ * @param {Object} options
+ * @param {WatcherActor} options.watcher
+ * The Watcher Actor providing new data entries
+ * @param {string} options.type
+ * The type of data to be added
+ * @param {Array<Object>} options.entries
+ * The values to be added to this type of data
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ let expectedCount = Services.ppmm.childCount - 1;
+ if (expectedCount == 0) {
+ return;
+ }
+ const onAllReplied = new Promise(resolve => {
+ let count = 0;
+ const listener = msg => {
+ if (msg.data.watcherActorID != watcher.actorID) {
+ return;
+ }
+ count++;
+ maybeResolve();
+ };
+ Services.ppmm.addMessageListener(
+ "debug:add-or-set-session-data-entry-done",
+ listener
+ );
+ const onContentProcessClosed = (messageManager, topic, data) => {
+ expectedCount--;
+ maybeResolve();
+ };
+ const maybeResolve = () => {
+ if (count == expectedCount) {
+ Services.ppmm.removeMessageListener(
+ "debug:add-or-set-session-data-entry-done",
+ listener
+ );
+ Services.obs.removeObserver(
+ onContentProcessClosed,
+ "message-manager-close"
+ );
+ resolve();
+ }
+ };
+ Services.obs.addObserver(onContentProcessClosed, "message-manager-close");
+ });
+
+ Services.ppmm.broadcastAsyncMessage("debug:add-or-set-session-data-entry", {
+ watcherActorID: watcher.actorID,
+ type,
+ entries,
+ updateType,
+ });
+
+ await onAllReplied;
+}
+
+/**
+ * Notify all existing content processes that some data entries have been removed
+ *
+ * See addOrSetSessionDataEntry for argument documentation.
+ */
+function removeSessionDataEntry({ watcher, type, entries }) {
+ Services.ppmm.broadcastAsyncMessage("debug:remove-session-data-entry", {
+ watcherActorID: watcher.actorID,
+ type,
+ entries,
+ });
+}
+
+module.exports = {
+ createTargets,
+ destroyTargets,
+ addOrSetSessionDataEntry,
+ removeSessionDataEntry,
+};
diff --git a/devtools/server/actors/watcher/target-helpers/service-worker-helper.js b/devtools/server/actors/watcher/target-helpers/service-worker-helper.js
new file mode 100644
index 0000000000..53fceead17
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/service-worker-helper.js
@@ -0,0 +1,220 @@
+/* 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 { waitForTick } = require("resource://devtools/shared/DevToolsUtils.js");
+
+const PROCESS_SCRIPT_URL =
+ "resource://devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js";
+
+const PROCESS_ACTOR_NAME = "DevToolsServiceWorker";
+const PROCESS_ACTOR_OPTIONS = {
+ // Ignore the parent process.
+ includeParent: false,
+
+ parent: {
+ esModuleURI:
+ "resource://devtools/server/connectors/process-actor/DevToolsServiceWorkerParent.sys.mjs",
+ },
+
+ child: {
+ esModuleURI:
+ "resource://devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs",
+
+ observers: [
+ // Tried various notification to ensure starting the actor
+ // from webServiceWorker processes... but none of them worked.
+ /*
+ "chrome-event-target-created",
+ "webnavigation-create",
+ "chrome-webnavigation-create",
+ "webnavigation-destroy",
+ "chrome-webnavigation-destroy",
+ "browsing-context-did-set-embedder",
+ "browsing-context-discarded",
+ "ipc:content-initializing",
+ "ipc:content-created",
+ */
+
+ // Fallback on firing a very custom notification from a "process script" (loadProcessScript)
+ "init-devtools-service-worker-actor",
+ ],
+ },
+};
+
+// List of all active watchers
+const gWatchers = new Set();
+
+/**
+ * Register the DevToolsServiceWorker JS Process Actor,
+ * if we are registering the first watcher actor.
+ *
+ * @param {Watcher Actor} watcher
+ */
+function maybeRegisterProcessActor(watcher) {
+ const sizeBefore = gWatchers.size;
+ gWatchers.add(watcher);
+
+ if (sizeBefore == 0 && gWatchers.size == 1) {
+ ChromeUtils.registerProcessActor(PROCESS_ACTOR_NAME, PROCESS_ACTOR_OPTIONS);
+
+ // For some reason JSProcessActor doesn't work out of the box for `webServiceWorker` content processes.
+ // So manually spawn our JSProcessActor from a process script emitting an observer service notification...
+ // The Process script are correctly executed on all process types during their early startup.
+ Services.ppmm.loadProcessScript(PROCESS_SCRIPT_URL, true);
+ }
+}
+
+/**
+ * Unregister the DevToolsServiceWorker JS Process Actor,
+ * if we are unregistering the last watcher actor.
+ *
+ * @param {Watcher Actor} watcher
+ */
+function maybeUnregisterProcessActor(watcher) {
+ const sizeBefore = gWatchers.size;
+ gWatchers.delete(watcher);
+
+ if (sizeBefore == 1 && gWatchers.size == 0) {
+ ChromeUtils.unregisterProcessActor(
+ PROCESS_ACTOR_NAME,
+ PROCESS_ACTOR_OPTIONS
+ );
+
+ Services.ppmm.removeDelayedProcessScript(PROCESS_SCRIPT_URL);
+ }
+}
+
+/**
+ * Return the list of all DOM Processes except the one for the parent process
+ *
+ * @return Array<nsIDOMProcessParent>
+ */
+function getAllContentProcesses() {
+ return ChromeUtils.getAllDOMProcesses().filter(
+ process => process.childID !== 0
+ );
+}
+
+/**
+ * Force creating targets for all existing service workers for a given Watcher Actor.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to watch for new targets.
+ */
+async function createTargets(watcher) {
+ maybeRegisterProcessActor(watcher);
+ // Go over all existing content process in order to:
+ // - Force the instantiation of a DevToolsServiceWorkerChild
+ // - Have the DevToolsServiceWorkerChild to spawn the WorkerTargetActors
+
+ const promises = [];
+ for (const process of getAllContentProcesses()) {
+ const promise = process
+ .getActor(PROCESS_ACTOR_NAME)
+ .instantiateServiceWorkerTargets({
+ watcherActorID: watcher.actorID,
+ connectionPrefix: watcher.conn.prefix,
+ sessionContext: watcher.sessionContext,
+ sessionData: watcher.sessionData,
+ });
+ promises.push(promise);
+ }
+
+ // Await for the different queries in order to try to resolve only *after* we received
+ // the already available worker targets.
+ return Promise.all(promises);
+}
+
+/**
+ * Force destroying all worker targets which were related to a given watcher.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to stop watching for new targets.
+ */
+async function destroyTargets(watcher) {
+ // Go over all existing content processes in order to destroy all targets
+ for (const process of getAllContentProcesses()) {
+ let processActor;
+ try {
+ processActor = process.getActor(PROCESS_ACTOR_NAME);
+ } catch (e) {
+ // Ignore any exception during destroy as we may be closing firefox/devtools/tab
+ // and that can easily lead to many exceptions.
+ continue;
+ }
+
+ processActor.destroyServiceWorkerTargets({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ });
+ }
+
+ // browser_dbg-breakpoints-columns.js crashes if we unregister the Process Actor
+ // in the same event loop as we call destroyServiceWorkerTargets.
+ await waitForTick();
+
+ maybeUnregisterProcessActor(watcher);
+}
+
+/**
+ * Go over all existing JSProcessActor in order to communicate about new data entries
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to update data entries.
+ * @param string type
+ * The type of data to be added
+ * @param Array<Object> entries
+ * The values to be added to this type of data
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ maybeRegisterProcessActor(watcher);
+ const promises = [];
+ for (const process of getAllContentProcesses()) {
+ const promise = process
+ .getActor(PROCESS_ACTOR_NAME)
+ .addOrSetSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ promises.push(promise);
+ }
+ // Await for the queries in order to try to resolve only *after* the remote code processed the new data
+ return Promise.all(promises);
+}
+
+/**
+ * Notify all existing frame targets that some data entries have been removed
+ *
+ * See addOrSetSessionDataEntry for argument documentation.
+ */
+function removeSessionDataEntry({ watcher, type, entries }) {
+ for (const process of getAllContentProcesses()) {
+ process.getActor(PROCESS_ACTOR_NAME).removeSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ });
+ }
+}
+
+module.exports = {
+ createTargets,
+ destroyTargets,
+ addOrSetSessionDataEntry,
+ removeSessionDataEntry,
+};
diff --git a/devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js b/devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.js
new file mode 100644
index 0000000000..03f042ad68
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/service-worker-jsprocessactor-startup.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 { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+/*
+ We can't spawn the JSProcessActor right away and have to spin the event loop.
+ Otherwise it isn't registered yet and isn't listening to observer service.
+ Could it be the reason why JSProcessActor aren't spawn via process actor option's child.observers notifications ??
+*/
+setTimeout(function () {
+ /*
+ This notification is registered in DevToolsServiceWorker JS process actor's options's `observers` attribute
+ and will force the JS Process actor to be instantiated in all processes.
+ */
+ Services.obs.notifyObservers(null, "init-devtools-service-worker-actor");
+ /*
+ Instead of using observer service, we could also manually call some method of the actor:
+ ChromeUtils.domProcessChild.getActor("DevToolsServiceWorker").observe(null, "foo");
+ */
+}, 0);
diff --git a/devtools/server/actors/watcher/target-helpers/worker-helper.js b/devtools/server/actors/watcher/target-helpers/worker-helper.js
new file mode 100644
index 0000000000..671d1dc706
--- /dev/null
+++ b/devtools/server/actors/watcher/target-helpers/worker-helper.js
@@ -0,0 +1,137 @@
+/* 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 DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME = "DevToolsWorker";
+
+/**
+ * Force creating targets for all existing workers for a given Watcher Actor.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to watch for new targets.
+ */
+async function createTargets(watcher) {
+ // Go over all existing BrowsingContext in order to:
+ // - Force the instantiation of a DevToolsWorkerChild
+ // - Have the DevToolsWorkerChild to spawn the WorkerTargetActors
+ const browsingContexts = watcher.getAllBrowsingContexts({
+ acceptSameProcessIframes: true,
+ forceAcceptTopLevelTarget: true,
+ });
+ const promises = [];
+ for (const browsingContext of browsingContexts) {
+ const promise = browsingContext.currentWindowGlobal
+ .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME)
+ .instantiateWorkerTargets({
+ watcherActorID: watcher.actorID,
+ connectionPrefix: watcher.conn.prefix,
+ sessionContext: watcher.sessionContext,
+ sessionData: watcher.sessionData,
+ });
+ promises.push(promise);
+ }
+
+ // Await for the different queries in order to try to resolve only *after* we received
+ // the already available worker targets.
+ return Promise.all(promises);
+}
+
+/**
+ * Force destroying all worker targets which were related to a given watcher.
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to stop watching for new targets.
+ */
+async function destroyTargets(watcher) {
+ // Go over all existing BrowsingContext in order to destroy all targets
+ const browsingContexts = watcher.getAllBrowsingContexts({
+ acceptSameProcessIframes: true,
+ forceAcceptTopLevelTarget: true,
+ });
+ for (const browsingContext of browsingContexts) {
+ let windowActor;
+ try {
+ windowActor = browsingContext.currentWindowGlobal.getActor(
+ DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME
+ );
+ } catch (e) {
+ continue;
+ }
+
+ windowActor.destroyWorkerTargets({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ });
+ }
+}
+
+/**
+ * Go over all existing BrowsingContext in order to communicate about new data entries
+ *
+ * @param WatcherActor watcher
+ * The Watcher Actor requesting to stop watching for new targets.
+ * @param string type
+ * The type of data to be added
+ * @param Array<Object> entries
+ * The values to be added to this type of data
+ * @param String updateType
+ * "add" will only add the new entries in the existing data set.
+ * "set" will update the data set with the new entries.
+ */
+async function addOrSetSessionDataEntry({
+ watcher,
+ type,
+ entries,
+ updateType,
+}) {
+ const browsingContexts = watcher.getAllBrowsingContexts({
+ acceptSameProcessIframes: true,
+ forceAcceptTopLevelTarget: true,
+ });
+ const promises = [];
+ for (const browsingContext of browsingContexts) {
+ const promise = browsingContext.currentWindowGlobal
+ .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME)
+ .addOrSetSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ promises.push(promise);
+ }
+ // Await for the queries in order to try to resolve only *after* the remote code processed the new data
+ return Promise.all(promises);
+}
+
+/**
+ * Notify all existing frame targets that some data entries have been removed
+ *
+ * See addOrSetSessionDataEntry for argument documentation.
+ */
+function removeSessionDataEntry({ watcher, type, entries }) {
+ const browsingContexts = watcher.getAllBrowsingContexts({
+ acceptSameProcessIframes: true,
+ forceAcceptTopLevelTarget: true,
+ });
+ for (const browsingContext of browsingContexts) {
+ browsingContext.currentWindowGlobal
+ .getActor(DEVTOOLS_WORKER_JS_WINDOW_ACTOR_NAME)
+ .removeSessionDataEntry({
+ watcherActorID: watcher.actorID,
+ sessionContext: watcher.sessionContext,
+ type,
+ entries,
+ });
+ }
+}
+
+module.exports = {
+ createTargets,
+ destroyTargets,
+ addOrSetSessionDataEntry,
+ removeSessionDataEntry,
+};
diff --git a/devtools/server/actors/webbrowser.js b/devtools/server/actors/webbrowser.js
new file mode 100644
index 0000000000..c05a863839
--- /dev/null
+++ b/devtools/server/actors/webbrowser.js
@@ -0,0 +1,776 @@
+/* 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";
+
+var {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+var {
+ ActorRegistry,
+} = require("resource://devtools/server/actors/utils/actor-registry.js");
+var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+
+loader.lazyRequireGetter(
+ this,
+ "RootActor",
+ "resource://devtools/server/actors/root.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "TabDescriptorActor",
+ "resource://devtools/server/actors/descriptors/tab.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WebExtensionDescriptorActor",
+ "resource://devtools/server/actors/descriptors/webextension.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WorkerDescriptorActorList",
+ "resource://devtools/server/actors/worker/worker-descriptor-actor-list.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ServiceWorkerRegistrationActorList",
+ "resource://devtools/server/actors/worker/service-worker-registration-list.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ProcessActorList",
+ "resource://devtools/server/actors/process.js",
+ true
+);
+const lazy = {};
+loader.lazyGetter(lazy, "AddonManager", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs",
+ { loadInDevToolsLoader: false }
+ ).AddonManager;
+});
+
+/**
+ * Browser-specific actors.
+ */
+
+/**
+ * Retrieve the window type of the top-level window |window|.
+ */
+function appShellDOMWindowType(window) {
+ /* This is what nsIWindowMediator's enumerator checks. */
+ return window.document.documentElement.getAttribute("windowtype");
+}
+
+/**
+ * Send Debugger:Shutdown events to all "navigator:browser" windows.
+ */
+function sendShutdownEvent() {
+ for (const win of Services.wm.getEnumerator(
+ DevToolsServer.chromeWindowType
+ )) {
+ const evt = win.document.createEvent("Event");
+ evt.initEvent("Debugger:Shutdown", true, false);
+ win.document.documentElement.dispatchEvent(evt);
+ }
+}
+
+exports.sendShutdownEvent = sendShutdownEvent;
+
+/**
+ * Construct a root actor appropriate for use in a server running in a
+ * browser. The returned root actor:
+ * - respects the factories registered with ActorRegistry.addGlobalActor,
+ * - uses a BrowserTabList to supply target actors for tabs,
+ * - sends all navigator:browser window documents a Debugger:Shutdown event
+ * when it exits.
+ *
+ * * @param connection DevToolsServerConnection
+ * The conection to the client.
+ */
+exports.createRootActor = function createRootActor(connection) {
+ return new RootActor(connection, {
+ tabList: new BrowserTabList(connection),
+ addonList: new BrowserAddonList(connection),
+ workerList: new WorkerDescriptorActorList(connection, {}),
+ serviceWorkerRegistrationList: new ServiceWorkerRegistrationActorList(
+ connection
+ ),
+ processList: new ProcessActorList(),
+ globalActorFactories: ActorRegistry.globalActorFactories,
+ onShutdown: sendShutdownEvent,
+ });
+};
+
+/**
+ * A live list of TabDescriptorActors representing the current browser tabs,
+ * to be provided to the root actor to answer 'listTabs' requests.
+ *
+ * This object also takes care of listening for TabClose events and
+ * onCloseWindow notifications, and exiting the target actors concerned.
+ *
+ * (See the documentation for RootActor for the definition of the "live
+ * list" interface.)
+ *
+ * @param connection DevToolsServerConnection
+ * The connection in which this list's target actors may participate.
+ *
+ * Some notes:
+ *
+ * This constructor is specific to the desktop browser environment; it
+ * maintains the tab list by tracking XUL windows and their XUL documents'
+ * "tabbrowser", "tab", and "browser" elements. What's entailed in maintaining
+ * an accurate list of open tabs in this context?
+ *
+ * - Opening and closing XUL windows:
+ *
+ * An nsIWindowMediatorListener is notified when new XUL windows (i.e., desktop
+ * windows) are opened and closed. It is not notified of individual content
+ * browser tabs coming and going within such a XUL window. That seems
+ * reasonable enough; it's concerned with XUL windows, not tab elements in the
+ * window's XUL document.
+ *
+ * However, even if we attach TabOpen and TabClose event listeners to each XUL
+ * window as soon as it is created:
+ *
+ * - we do not receive a TabOpen event for the initial empty tab of a new XUL
+ * window; and
+ *
+ * - we do not receive TabClose events for the tabs of a XUL window that has
+ * been closed.
+ *
+ * This means that TabOpen and TabClose events alone are not sufficient to
+ * maintain an accurate list of live tabs and mark target actors as closed
+ * promptly. Our nsIWindowMediatorListener onCloseWindow handler must find and
+ * exit all actors for tabs that were in the closing window.
+ *
+ * Since this is a bit hairy, we don't make each individual attached target
+ * actor responsible for noticing when it has been closed; we watch for that,
+ * and promise to call each actor's 'exit' method when it's closed, regardless
+ * of how we learn the news.
+ *
+ * - nsIWindowMediator locks
+ *
+ * nsIWindowMediator holds a lock protecting its list of top-level windows
+ * while it calls nsIWindowMediatorListener methods. nsIWindowMediator's
+ * GetEnumerator method also tries to acquire that lock. Thus, enumerating
+ * windows from within a listener method deadlocks (bug 873589). Rah. One
+ * can sometimes work around this by leaving the enumeration for a later
+ * tick.
+ *
+ * - Dragging tabs between windows:
+ *
+ * When a tab is dragged from one desktop window to another, we receive a
+ * TabOpen event for the new tab, and a TabClose event for the old tab; tab XUL
+ * elements do not really move from one document to the other (although their
+ * linked browser's content window objects do).
+ *
+ * However, while we could thus assume that each tab stays with the XUL window
+ * it belonged to when it was created, I'm not sure this is behavior one should
+ * rely upon. When a XUL window is closed, we take the less efficient, more
+ * conservative approach of simply searching the entire table for actors that
+ * belong to the closing XUL window, rather than trying to somehow track which
+ * XUL window each tab belongs to.
+ */
+function BrowserTabList(connection) {
+ this._connection = connection;
+
+ /*
+ * The XUL document of a tabbed browser window has "tab" elements, whose
+ * 'linkedBrowser' JavaScript properties are "browser" elements; those
+ * browsers' 'contentWindow' properties are wrappers on the tabs' content
+ * window objects.
+ *
+ * This map's keys are "browser" XUL elements; it maps each browser element
+ * to the target actor we've created for its content window, if we've created
+ * one. This map serves several roles:
+ *
+ * - During iteration, we use it to find actors we've created previously.
+ *
+ * - On a TabClose event, we use it to find the tab's target actor and exit it.
+ *
+ * - When the onCloseWindow handler is called, we iterate over it to find all
+ * tabs belonging to the closing XUL window, and exit them.
+ *
+ * - When it's empty, and the onListChanged hook is null, we know we can
+ * stop listening for events and notifications.
+ *
+ * We listen for TabClose events and onCloseWindow notifications in order to
+ * send onListChanged notifications, but also to tell actors when their
+ * referent has gone away and remove entries for dead browsers from this map.
+ * If that code is working properly, neither this map nor the actors in it
+ * should ever hold dead tabs alive.
+ */
+ this._actorByBrowser = new Map();
+
+ /* The current onListChanged handler, or null. */
+ this._onListChanged = null;
+
+ /*
+ * True if we've been iterated over since we last called our onListChanged
+ * hook.
+ */
+ this._mustNotify = false;
+
+ /* True if we're testing, and should throw if consistency checks fail. */
+ this._testing = false;
+
+ this._onPageTitleChangedEvent = this._onPageTitleChangedEvent.bind(this);
+}
+
+BrowserTabList.prototype.constructor = BrowserTabList;
+
+BrowserTabList.prototype.destroy = function () {
+ this._actorByBrowser.clear();
+ this.onListChanged = null;
+};
+
+/**
+ * Get the selected browser for the given navigator:browser window.
+ * @private
+ * @param window nsIChromeWindow
+ * The navigator:browser window for which you want the selected browser.
+ * @return Element|null
+ * The currently selected xul:browser element, if any. Note that the
+ * browser window might not be loaded yet - the function will return
+ * |null| in such cases.
+ */
+BrowserTabList.prototype._getSelectedBrowser = function (window) {
+ return window.gBrowser ? window.gBrowser.selectedBrowser : null;
+};
+
+/**
+ * Produces an iterable (in this case a generator) to enumerate all available
+ * browser tabs.
+ */
+BrowserTabList.prototype._getBrowsers = function* () {
+ // Iterate over all navigator:browser XUL windows.
+ for (const win of Services.wm.getEnumerator(
+ DevToolsServer.chromeWindowType
+ )) {
+ // For each tab in this XUL window, ensure that we have an actor for
+ // it, reusing existing actors where possible.
+ for (const browser of this._getChildren(win)) {
+ yield browser;
+ }
+ }
+};
+
+BrowserTabList.prototype._getChildren = function (window) {
+ if (!window.gBrowser) {
+ return [];
+ }
+ const { gBrowser } = window;
+ if (!gBrowser.browsers) {
+ return [];
+ }
+ return gBrowser.browsers.filter(browser => {
+ // Filter tabs that are closing. listTabs calls made right after TabClose
+ // events still list tabs in process of being closed.
+ const tab = gBrowser.getTabForBrowser(browser);
+ return !tab.closing;
+ });
+};
+
+BrowserTabList.prototype.getList = async function () {
+ // As a sanity check, make sure all the actors presently in our map get
+ // picked up when we iterate over all windows' tabs.
+ const initialMapSize = this._actorByBrowser.size;
+ this._foundCount = 0;
+
+ const actors = [];
+
+ for (const browser of this._getBrowsers()) {
+ try {
+ const actor = await this._getActorForBrowser(browser);
+ actors.push(actor);
+ } catch (e) {
+ if (e.error === "tabDestroyed") {
+ // Ignore the error if a tab was destroyed while retrieving the tab list.
+ continue;
+ }
+
+ // Forward unexpected errors.
+ throw e;
+ }
+ }
+
+ if (this._testing && initialMapSize !== this._foundCount) {
+ throw new Error("_actorByBrowser map contained actors for dead tabs");
+ }
+
+ this._mustNotify = true;
+ this._checkListening();
+
+ return actors;
+};
+
+BrowserTabList.prototype._getActorForBrowser = async function (browser) {
+ // Do we have an existing actor for this browser? If not, create one.
+ let actor = this._actorByBrowser.get(browser);
+ if (actor) {
+ this._foundCount++;
+ return actor;
+ }
+
+ actor = new TabDescriptorActor(this._connection, browser);
+ this._actorByBrowser.set(browser, actor);
+ this._checkListening();
+ return actor;
+};
+
+/**
+ * Return the tab descriptor :
+ * - for the tab matching a browserId if one is passed
+ * - OR the currently selected tab if no browserId is passed.
+ *
+ * @param {Number} browserId: use to match any tab
+ */
+BrowserTabList.prototype.getTab = function ({ browserId }) {
+ if (typeof browserId == "number") {
+ const browsingContext = BrowsingContext.getCurrentTopByBrowserId(browserId);
+ if (!browsingContext) {
+ return Promise.reject({
+ error: "noTab",
+ message: `Unable to find tab with browserId '${browserId}' (no browsing-context)`,
+ });
+ }
+ const browser = browsingContext.embedderElement;
+ if (!browser) {
+ return Promise.reject({
+ error: "noTab",
+ message: `Unable to find tab with browserId '${browserId}' (no embedder element)`,
+ });
+ }
+ return this._getActorForBrowser(browser);
+ }
+
+ const topAppWindow = Services.wm.getMostRecentWindow(
+ DevToolsServer.chromeWindowType
+ );
+ if (topAppWindow) {
+ const selectedBrowser = this._getSelectedBrowser(topAppWindow);
+ return this._getActorForBrowser(selectedBrowser);
+ }
+ return Promise.reject({
+ error: "noTab",
+ message: "Unable to find any selected browser",
+ });
+};
+
+Object.defineProperty(BrowserTabList.prototype, "onListChanged", {
+ enumerable: true,
+ configurable: true,
+ get() {
+ return this._onListChanged;
+ },
+ set(v) {
+ if (v !== null && typeof v !== "function") {
+ throw new Error(
+ "onListChanged property may only be set to 'null' or a function"
+ );
+ }
+ this._onListChanged = v;
+ this._checkListening();
+ },
+});
+
+/**
+ * The set of tabs has changed somehow. Call our onListChanged handler, if
+ * one is set, and if we haven't already called it since the last iteration.
+ */
+BrowserTabList.prototype._notifyListChanged = function () {
+ if (!this._onListChanged) {
+ return;
+ }
+ if (this._mustNotify) {
+ this._onListChanged();
+ this._mustNotify = false;
+ }
+};
+
+/**
+ * Exit |actor|, belonging to |browser|, and notify the onListChanged
+ * handle if needed.
+ */
+BrowserTabList.prototype._handleActorClose = function (actor, browser) {
+ if (this._testing) {
+ if (this._actorByBrowser.get(browser) !== actor) {
+ throw new Error(
+ "TabDescriptorActor not stored in map under given browser"
+ );
+ }
+ if (actor.browser !== browser) {
+ throw new Error("actor's browser and map key don't match");
+ }
+ }
+
+ this._actorByBrowser.delete(browser);
+ actor.destroy();
+
+ this._notifyListChanged();
+ this._checkListening();
+};
+
+/**
+ * Make sure we are listening or not listening for activity elsewhere in
+ * the browser, as appropriate. Other than setting up newly created XUL
+ * windows, all listener / observer management should happen here.
+ */
+BrowserTabList.prototype._checkListening = function () {
+ /*
+ * If we have an onListChanged handler that we haven't sent an announcement
+ * to since the last iteration, we need to watch for tab creation as well as
+ * change of the currently selected tab and tab title changes of tabs in
+ * parent process via TabAttrModified (tabs oop uses DOMTitleChanges).
+ *
+ * Oddly, we don't need to watch for 'close' events here. If our actor list
+ * is empty, then either it was empty the last time we iterated, and no
+ * close events are possible, or it was not empty the last time we
+ * iterated, but all the actors have since been closed, and we must have
+ * sent a notification already when they closed.
+ */
+ this._listenForEventsIf(
+ this._onListChanged && this._mustNotify,
+ "_listeningForTabOpen",
+ ["TabOpen", "TabSelect", "TabAttrModified"]
+ );
+
+ /* If we have live actors, we need to be ready to mark them dead. */
+ this._listenForEventsIf(
+ this._actorByBrowser.size > 0,
+ "_listeningForTabClose",
+ ["TabClose"]
+ );
+
+ /*
+ * We must listen to the window mediator in either case, since that's the
+ * only way to find out about tabs that come and go when top-level windows
+ * are opened and closed.
+ */
+ this._listenToMediatorIf(
+ (this._onListChanged && this._mustNotify) || this._actorByBrowser.size > 0
+ );
+
+ /*
+ * We also listen for title changed events on the browser.
+ */
+ this._listenForEventsIf(
+ this._onListChanged && this._mustNotify,
+ "_listeningForTitleChange",
+ ["pagetitlechanged"],
+ this._onPageTitleChangedEvent
+ );
+};
+
+/*
+ * Add or remove event listeners for all XUL windows.
+ *
+ * @param shouldListen boolean
+ * True if we should add event handlers; false if we should remove them.
+ * @param guard string
+ * The name of a guard property of 'this', indicating whether we're
+ * already listening for those events.
+ * @param eventNames array of strings
+ * An array of event names.
+ */
+BrowserTabList.prototype._listenForEventsIf = function (
+ shouldListen,
+ guard,
+ eventNames,
+ listener = this
+) {
+ if (!shouldListen !== !this[guard]) {
+ const op = shouldListen ? "addEventListener" : "removeEventListener";
+ for (const win of Services.wm.getEnumerator(
+ DevToolsServer.chromeWindowType
+ )) {
+ for (const name of eventNames) {
+ win[op](name, listener, false);
+ }
+ }
+ this[guard] = shouldListen;
+ }
+};
+
+/*
+ * Event listener for pagetitlechanged event.
+ */
+BrowserTabList.prototype._onPageTitleChangedEvent = function (event) {
+ switch (event.type) {
+ case "pagetitlechanged": {
+ const browser = event.target;
+ this._onDOMTitleChanged(browser);
+ break;
+ }
+ }
+};
+
+/**
+ * Handle "DOMTitleChanged" event.
+ */
+BrowserTabList.prototype._onDOMTitleChanged = DevToolsUtils.makeInfallible(
+ function (browser) {
+ const actor = this._actorByBrowser.get(browser);
+ if (actor) {
+ this._notifyListChanged();
+ this._checkListening();
+ }
+ }
+);
+
+/**
+ * Implement nsIDOMEventListener.
+ */
+BrowserTabList.prototype.handleEvent = DevToolsUtils.makeInfallible(function (
+ event
+) {
+ // If event target has `linkedBrowser`, the event target can be assumed <tab> element.
+ // Else, event target is assumed <browser> element, use the target as it is.
+ const browser = event.target.linkedBrowser || event.target;
+ switch (event.type) {
+ case "TabOpen":
+ case "TabSelect": {
+ /* Don't create a new actor; iterate will take care of that. Just notify. */
+ this._notifyListChanged();
+ this._checkListening();
+ break;
+ }
+ case "TabClose": {
+ const actor = this._actorByBrowser.get(browser);
+ if (actor) {
+ this._handleActorClose(actor, browser);
+ }
+ break;
+ }
+ case "TabAttrModified": {
+ // Remote <browser> title changes are handled via DOMTitleChange message
+ // TabAttrModified is only here for browsers in parent process which
+ // don't send this message.
+ if (browser.isRemoteBrowser) {
+ break;
+ }
+ const actor = this._actorByBrowser.get(browser);
+ if (actor) {
+ // TabAttrModified is fired in various cases, here only care about title
+ // changes
+ if (event.detail.changed.includes("label")) {
+ this._notifyListChanged();
+ this._checkListening();
+ }
+ }
+ break;
+ }
+ }
+},
+"BrowserTabList.prototype.handleEvent");
+
+/*
+ * If |shouldListen| is true, ensure we've registered a listener with the
+ * window mediator. Otherwise, ensure we haven't registered a listener.
+ */
+BrowserTabList.prototype._listenToMediatorIf = function (shouldListen) {
+ if (!shouldListen !== !this._listeningToMediator) {
+ const op = shouldListen ? "addListener" : "removeListener";
+ Services.wm[op](this);
+ this._listeningToMediator = shouldListen;
+ }
+};
+
+/**
+ * nsIWindowMediatorListener implementation.
+ *
+ * See _onTabClosed for explanation of why we needn't actually tweak any
+ * actors or tables here.
+ *
+ * An nsIWindowMediatorListener's methods get passed all sorts of windows; we
+ * only care about the tab containers. Those have 'gBrowser' members.
+ */
+BrowserTabList.prototype.onOpenWindow = DevToolsUtils.makeInfallible(function (
+ window
+) {
+ const handleLoad = DevToolsUtils.makeInfallible(() => {
+ /* We don't want any further load events from this window. */
+ window.removeEventListener("load", handleLoad);
+
+ if (appShellDOMWindowType(window) !== DevToolsServer.chromeWindowType) {
+ return;
+ }
+
+ // Listen for future tab activity.
+ if (this._listeningForTabOpen) {
+ window.addEventListener("TabOpen", this);
+ window.addEventListener("TabSelect", this);
+ window.addEventListener("TabAttrModified", this);
+ }
+ if (this._listeningForTabClose) {
+ window.addEventListener("TabClose", this);
+ }
+ if (this._listeningForTitleChange) {
+ window.messageManager.addMessageListener("DOMTitleChanged", this);
+ }
+
+ // As explained above, we will not receive a TabOpen event for this
+ // document's initial tab, so we must notify our client of the new tab
+ // this will have.
+ this._notifyListChanged();
+ });
+
+ /*
+ * You can hardly do anything at all with a XUL window at this point; it
+ * doesn't even have its document yet. Wait until its document has
+ * loaded, and then see what we've got. This also avoids
+ * nsIWindowMediator enumeration from within listeners (bug 873589).
+ */
+ window = window
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindow);
+
+ window.addEventListener("load", handleLoad);
+},
+"BrowserTabList.prototype.onOpenWindow");
+
+BrowserTabList.prototype.onCloseWindow = DevToolsUtils.makeInfallible(function (
+ window
+) {
+ if (window instanceof Ci.nsIAppWindow) {
+ window = window.docShell.domWindow;
+ }
+
+ if (appShellDOMWindowType(window) !== DevToolsServer.chromeWindowType) {
+ return;
+ }
+
+ /*
+ * nsIWindowMediator deadlocks if you call its GetEnumerator method from
+ * a nsIWindowMediatorListener's onCloseWindow hook (bug 873589), so
+ * handle the close in a different tick.
+ */
+ Services.tm.dispatchToMainThread(
+ DevToolsUtils.makeInfallible(() => {
+ /*
+ * Scan the entire map for actors representing tabs that were in this
+ * top-level window, and exit them.
+ */
+ for (const [browser, actor] of this._actorByBrowser) {
+ /* The browser document of a closed window has no default view. */
+ if (!browser.ownerGlobal) {
+ this._handleActorClose(actor, browser);
+ }
+ }
+ }, "BrowserTabList.prototype.onCloseWindow's delayed body")
+ );
+},
+"BrowserTabList.prototype.onCloseWindow");
+
+exports.BrowserTabList = BrowserTabList;
+
+function BrowserAddonList(connection) {
+ this._connection = connection;
+ this._actorByAddonId = new Map();
+ this._onListChanged = null;
+}
+
+BrowserAddonList.prototype.getList = async function () {
+ const addons = await lazy.AddonManager.getAllAddons();
+ for (const addon of addons) {
+ let actor = this._actorByAddonId.get(addon.id);
+ if (!actor) {
+ actor = new WebExtensionDescriptorActor(this._connection, addon);
+ this._actorByAddonId.set(addon.id, actor);
+ }
+ }
+
+ return Array.from(this._actorByAddonId, ([_, actor]) => actor);
+};
+
+Object.defineProperty(BrowserAddonList.prototype, "onListChanged", {
+ enumerable: true,
+ configurable: true,
+ get() {
+ return this._onListChanged;
+ },
+ set(v) {
+ if (v !== null && typeof v != "function") {
+ throw new Error(
+ "onListChanged property may only be set to 'null' or a function"
+ );
+ }
+ this._onListChanged = v;
+ this._adjustListener();
+ },
+});
+
+/**
+ * AddonManager listener must implement onDisabled.
+ */
+BrowserAddonList.prototype.onDisabled = function (addon) {
+ this._onAddonManagerUpdated();
+};
+
+/**
+ * AddonManager listener must implement onEnabled.
+ */
+BrowserAddonList.prototype.onEnabled = function (addon) {
+ this._onAddonManagerUpdated();
+};
+
+/**
+ * AddonManager listener must implement onInstalled.
+ */
+BrowserAddonList.prototype.onInstalled = function (addon) {
+ this._onAddonManagerUpdated();
+};
+
+/**
+ * AddonManager listener must implement onOperationCancelled.
+ */
+BrowserAddonList.prototype.onOperationCancelled = function (addon) {
+ this._onAddonManagerUpdated();
+};
+
+/**
+ * AddonManager listener must implement onUninstalling.
+ */
+BrowserAddonList.prototype.onUninstalling = function (addon) {
+ this._onAddonManagerUpdated();
+};
+
+/**
+ * AddonManager listener must implement onUninstalled.
+ */
+BrowserAddonList.prototype.onUninstalled = function (addon) {
+ this._actorByAddonId.delete(addon.id);
+ this._onAddonManagerUpdated();
+};
+
+BrowserAddonList.prototype._onAddonManagerUpdated = function (addon) {
+ this._notifyListChanged();
+ this._adjustListener();
+};
+
+BrowserAddonList.prototype._notifyListChanged = function () {
+ if (this._onListChanged) {
+ this._onListChanged();
+ }
+};
+
+BrowserAddonList.prototype._adjustListener = function () {
+ if (this._onListChanged) {
+ // As long as the callback exists, we need to listen for changes
+ // so we can notify about add-on changes.
+ lazy.AddonManager.addAddonListener(this);
+ } else if (this._actorByAddonId.size === 0) {
+ // When the callback does not exist, we only need to keep listening
+ // if the actor cache will need adjusting when add-ons change.
+ lazy.AddonManager.removeAddonListener(this);
+ }
+};
+
+exports.BrowserAddonList = BrowserAddonList;
diff --git a/devtools/server/actors/webconsole.js b/devtools/server/actors/webconsole.js
new file mode 100644
index 0000000000..14401b3fbe
--- /dev/null
+++ b/devtools/server/actors/webconsole.js
@@ -0,0 +1,1736 @@
+/* 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/. */
+
+/* global clearConsoleEvents */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ webconsoleSpec,
+} = require("resource://devtools/shared/specs/webconsole.js");
+
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const { ThreadActor } = require("resource://devtools/server/actors/thread.js");
+const { ObjectActor } = require("resource://devtools/server/actors/object.js");
+const {
+ LongStringActor,
+} = require("resource://devtools/server/actors/string.js");
+const {
+ createValueGrip,
+ isArray,
+ stringIsLong,
+} = require("resource://devtools/server/actors/object/utils.js");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const ErrorDocs = require("resource://devtools/server/actors/errordocs.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+
+loader.lazyRequireGetter(
+ this,
+ "evalWithDebugger",
+ "resource://devtools/server/actors/webconsole/eval-with-debugger.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ConsoleFileActivityListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-file-activity.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "jsPropertyProvider",
+ "resource://devtools/shared/webconsole/js-property-provider.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["isCommand"],
+ "resource://devtools/server/actors/webconsole/commands/parser.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["CONSOLE_WORKER_IDS", "WebConsoleUtils"],
+ "resource://devtools/server/actors/webconsole/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["WebConsoleCommandsManager"],
+ "resource://devtools/server/actors/webconsole/commands/manager.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EnvironmentActor",
+ "resource://devtools/server/actors/environment.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "MESSAGE_CATEGORY",
+ "resource://devtools/shared/constants.js",
+ true
+);
+
+// Generated by /devtools/shared/webconsole/GenerateReservedWordsJS.py
+loader.lazyRequireGetter(
+ this,
+ "RESERVED_JS_KEYWORDS",
+ "resource://devtools/shared/webconsole/reserved-js-words.js"
+);
+
+// Overwrite implemented listeners for workers so that we don't attempt
+// to load an unsupported module.
+if (isWorker) {
+ loader.lazyRequireGetter(
+ this,
+ ["ConsoleAPIListener", "ConsoleServiceListener"],
+ "resource://devtools/server/actors/webconsole/worker-listeners.js",
+ true
+ );
+} else {
+ loader.lazyRequireGetter(
+ this,
+ "ConsoleAPIListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-api.js",
+ true
+ );
+ loader.lazyRequireGetter(
+ this,
+ "ConsoleServiceListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-service.js",
+ true
+ );
+ loader.lazyRequireGetter(
+ this,
+ "ConsoleReflowListener",
+ "resource://devtools/server/actors/webconsole/listeners/console-reflow.js",
+ true
+ );
+ loader.lazyRequireGetter(
+ this,
+ "DocumentEventsListener",
+ "resource://devtools/server/actors/webconsole/listeners/document-events.js",
+ true
+ );
+}
+loader.lazyRequireGetter(
+ this,
+ "ObjectUtils",
+ "resource://devtools/server/actors/object/utils.js"
+);
+
+function isObject(value) {
+ return Object(value) === value;
+}
+
+/**
+ * The WebConsoleActor implements capabilities needed for the Web Console
+ * feature.
+ *
+ * @constructor
+ * @param object connection
+ * The connection to the client, DevToolsServerConnection.
+ * @param object [parentActor]
+ * Optional, the parent actor.
+ */
+class WebConsoleActor extends Actor {
+ constructor(connection, parentActor) {
+ super(connection, webconsoleSpec);
+
+ this.parentActor = parentActor;
+
+ this.dbg = this.parentActor.dbg;
+
+ this._gripDepth = 0;
+ this._evalCounter = 0;
+ this._listeners = new Set();
+ this._lastConsoleInputEvaluation = undefined;
+
+ this.objectGrip = this.objectGrip.bind(this);
+ this._onWillNavigate = this._onWillNavigate.bind(this);
+ this._onChangedToplevelDocument =
+ this._onChangedToplevelDocument.bind(this);
+ this.onConsoleServiceMessage = this.onConsoleServiceMessage.bind(this);
+ this.onConsoleAPICall = this.onConsoleAPICall.bind(this);
+ this.onDocumentEvent = this.onDocumentEvent.bind(this);
+
+ EventEmitter.on(
+ this.parentActor,
+ "changed-toplevel-document",
+ this._onChangedToplevelDocument
+ );
+ }
+
+ /**
+ * Debugger instance.
+ *
+ * @see jsdebugger.sys.mjs
+ */
+ dbg = null;
+
+ /**
+ * This is used by the ObjectActor to keep track of the depth of grip() calls.
+ * @private
+ * @type number
+ */
+ _gripDepth = null;
+
+ /**
+ * Holds a set of all currently registered listeners.
+ *
+ * @private
+ * @type Set
+ */
+ _listeners = null;
+
+ /**
+ * The global we work with (this can be a Window, a Worker global or even a Sandbox
+ * for processes and addons).
+ *
+ * @type nsIDOMWindow, WorkerGlobalScope or Sandbox
+ */
+ get global() {
+ if (this.parentActor.isRootActor) {
+ return this._getWindowForBrowserConsole();
+ }
+ return this.parentActor.window || this.parentActor.workerGlobal;
+ }
+
+ /**
+ * Get a window to use for the browser console.
+ *
+ * (note that is is also used for browser toolbox and webextension
+ * i.e. all targets flagged with isRootActor=true)
+ *
+ * @private
+ * @return nsIDOMWindow
+ * The window to use, or null if no window could be found.
+ */
+ _getWindowForBrowserConsole() {
+ // Check if our last used chrome window is still live.
+ let window = this._lastChromeWindow && this._lastChromeWindow.get();
+ // If not, look for a new one.
+ // In case of WebExtension reload of the background page, the last
+ // chrome window might be a dead wrapper, from which we can't check for window.closed.
+ if (!window || Cu.isDeadWrapper(window) || window.closed) {
+ window = this.parentActor.window;
+ if (!window) {
+ // Try to find the Browser Console window to use instead.
+ window = Services.wm.getMostRecentWindow("devtools:webconsole");
+ // We prefer the normal chrome window over the console window,
+ // so we'll look for those windows in order to replace our reference.
+ const onChromeWindowOpened = () => {
+ // We'll look for this window when someone next requests window()
+ Services.obs.removeObserver(onChromeWindowOpened, "domwindowopened");
+ this._lastChromeWindow = null;
+ };
+ Services.obs.addObserver(onChromeWindowOpened, "domwindowopened");
+ }
+
+ this._handleNewWindow(window);
+ }
+
+ return window;
+ }
+
+ /**
+ * Store a newly found window on the actor to be used in the future.
+ *
+ * @private
+ * @param nsIDOMWindow window
+ * The window to store on the actor (can be null).
+ */
+ _handleNewWindow(window) {
+ if (window) {
+ if (this._hadChromeWindow) {
+ Services.console.logStringMessage("Webconsole context has changed");
+ }
+ this._lastChromeWindow = Cu.getWeakReference(window);
+ this._hadChromeWindow = true;
+ } else {
+ this._lastChromeWindow = null;
+ }
+ }
+
+ /**
+ * Whether we've been using a window before.
+ *
+ * @private
+ * @type boolean
+ */
+ _hadChromeWindow = false;
+
+ /**
+ * A weak reference to the last chrome window we used to work with.
+ *
+ * @private
+ * @type nsIWeakReference
+ */
+ _lastChromeWindow = null;
+
+ // The evalGlobal is used at the scope for JS evaluation.
+ _evalGlobal = null;
+ get evalGlobal() {
+ return this._evalGlobal || this.global;
+ }
+
+ set evalGlobal(global) {
+ this._evalGlobal = global;
+
+ if (!this._progressListenerActive) {
+ EventEmitter.on(this.parentActor, "will-navigate", this._onWillNavigate);
+ this._progressListenerActive = true;
+ }
+ }
+
+ /**
+ * Flag used to track if we are listening for events from the progress
+ * listener of the target actor. We use the progress listener to clear
+ * this.evalGlobal on page navigation.
+ *
+ * @private
+ * @type boolean
+ */
+ _progressListenerActive = false;
+
+ /**
+ * The ConsoleServiceListener instance.
+ * @type object
+ */
+ consoleServiceListener = null;
+
+ /**
+ * The ConsoleAPIListener instance.
+ */
+ consoleAPIListener = null;
+
+ /**
+ * The ConsoleFileActivityListener instance.
+ */
+ consoleFileActivityListener = null;
+
+ /**
+ * The ConsoleReflowListener instance.
+ */
+ consoleReflowListener = null;
+
+ grip() {
+ return { actor: this.actorID };
+ }
+
+ _findProtoChain = ThreadActor.prototype._findProtoChain;
+ _removeFromProtoChain = ThreadActor.prototype._removeFromProtoChain;
+
+ /**
+ * Destroy the current WebConsoleActor instance.
+ */
+ destroy() {
+ this.stopListeners();
+ super.destroy();
+
+ EventEmitter.off(
+ this.parentActor,
+ "changed-toplevel-document",
+ this._onChangedToplevelDocument
+ );
+
+ this._lastConsoleInputEvaluation = null;
+ this._evalGlobal = null;
+ this.dbg = null;
+ }
+
+ /**
+ * Create and return an environment actor that corresponds to the provided
+ * Debugger.Environment. This is a straightforward clone of the ThreadActor's
+ * method except that it stores the environment actor in the web console
+ * actor's pool.
+ *
+ * @param Debugger.Environment environment
+ * The lexical environment we want to extract.
+ * @return The EnvironmentActor for |environment| or |undefined| for host
+ * functions or functions scoped to a non-debuggee global.
+ */
+ createEnvironmentActor(environment) {
+ if (!environment) {
+ return undefined;
+ }
+
+ if (environment.actor) {
+ return environment.actor;
+ }
+
+ const actor = new EnvironmentActor(environment, this);
+ this.manage(actor);
+ environment.actor = actor;
+
+ return actor;
+ }
+
+ /**
+ * Create a grip for the given value.
+ *
+ * @param mixed value
+ * @return object
+ */
+ createValueGrip(value) {
+ return createValueGrip(value, this, this.objectGrip);
+ }
+
+ /**
+ * Make a debuggee value for the given value.
+ *
+ * @param mixed value
+ * The value you want to get a debuggee value for.
+ * @param boolean useObjectGlobal
+ * If |true| the object global is determined and added as a debuggee,
+ * otherwise |this.global| is used when makeDebuggeeValue() is invoked.
+ * @return object
+ * Debuggee value for |value|.
+ */
+ makeDebuggeeValue(value, useObjectGlobal) {
+ if (useObjectGlobal && isObject(value)) {
+ try {
+ const global = Cu.getGlobalForObject(value);
+ const dbgGlobal = this.dbg.makeGlobalObjectReference(global);
+ return dbgGlobal.makeDebuggeeValue(value);
+ } catch (ex) {
+ // The above can throw an exception if value is not an actual object
+ // or 'Object in compartment marked as invisible to Debugger'
+ }
+ }
+ const dbgGlobal = this.dbg.makeGlobalObjectReference(this.global);
+ return dbgGlobal.makeDebuggeeValue(value);
+ }
+
+ /**
+ * Create a grip for the given object.
+ *
+ * @param object object
+ * The object you want.
+ * @param object pool
+ * A Pool where the new actor instance is added.
+ * @param object
+ * The object grip.
+ */
+ objectGrip(object, pool) {
+ const actor = new ObjectActor(
+ object,
+ {
+ thread: this.parentActor.threadActor,
+ getGripDepth: () => this._gripDepth,
+ incrementGripDepth: () => this._gripDepth++,
+ decrementGripDepth: () => this._gripDepth--,
+ createValueGrip: v => this.createValueGrip(v),
+ createEnvironmentActor: env => this.createEnvironmentActor(env),
+ },
+ this.conn
+ );
+ pool.manage(actor);
+ return actor.form();
+ }
+
+ /**
+ * Create a grip for the given string.
+ *
+ * @param string string
+ * The string you want to create the grip for.
+ * @param object pool
+ * A Pool where the new actor instance is added.
+ * @return object
+ * A LongStringActor object that wraps the given string.
+ */
+ longStringGrip(string, pool) {
+ const actor = new LongStringActor(this.conn, string);
+ pool.manage(actor);
+ return actor.form();
+ }
+
+ /**
+ * Create a long string grip if needed for the given string.
+ *
+ * @private
+ * @param string string
+ * The string you want to create a long string grip for.
+ * @return string|object
+ * A string is returned if |string| is not a long string.
+ * A LongStringActor grip is returned if |string| is a long string.
+ */
+ _createStringGrip(string) {
+ if (string && stringIsLong(string)) {
+ return this.longStringGrip(string, this);
+ }
+ return string;
+ }
+
+ /**
+ * Returns the latest web console input evaluation.
+ * This is undefined if no evaluations have been completed.
+ *
+ * @return object
+ */
+ getLastConsoleInputEvaluation() {
+ return this._lastConsoleInputEvaluation;
+ }
+
+ /**
+ * Preprocess a debugger object (e.g. return the `boundTargetFunction`
+ * debugger object if the given debugger object is a bound function).
+ *
+ * This method is called by both the `inspect` binding implemented
+ * for the webconsole and the one implemented for the devtools API
+ * `browser.devtools.inspectedWindow.eval`.
+ */
+ preprocessDebuggerObject(dbgObj) {
+ // Returns the bound target function on a bound function.
+ if (dbgObj?.isBoundFunction && dbgObj?.boundTargetFunction) {
+ return dbgObj.boundTargetFunction;
+ }
+
+ return dbgObj;
+ }
+
+ /**
+ * This helper is used by the WebExtensionInspectedWindowActor to
+ * inspect an object in the developer toolbox.
+ *
+ * NOTE: shared parts related to preprocess the debugger object (between
+ * this function and the `inspect` webconsole command defined in
+ * "devtools/server/actor/webconsole/utils.js") should be added to
+ * the webconsole actors' `preprocessDebuggerObject` method.
+ */
+ inspectObject(dbgObj, inspectFromAnnotation) {
+ dbgObj = this.preprocessDebuggerObject(dbgObj);
+ this.emit("inspectObject", {
+ objectActor: this.createValueGrip(dbgObj),
+ inspectFromAnnotation,
+ });
+ }
+
+ // Request handlers for known packet types.
+
+ /**
+ * Handler for the "startListeners" request.
+ *
+ * @param array listeners
+ * An array of events to start sent by the Web Console client.
+ * @return object
+ * The response object which holds the startedListeners array.
+ */
+ // eslint-disable-next-line complexity
+ async startListeners(listeners) {
+ const startedListeners = [];
+ const global = !this.parentActor.isRootActor ? this.global : null;
+ const isTargetActorContentProcess =
+ this.parentActor.targetType === Targets.TYPES.PROCESS;
+
+ for (const event of listeners) {
+ switch (event) {
+ case "PageError":
+ // Workers don't support this message type yet
+ if (isWorker) {
+ break;
+ }
+ if (!this.consoleServiceListener) {
+ this.consoleServiceListener = new ConsoleServiceListener(
+ global,
+ this.onConsoleServiceMessage,
+ {
+ matchExactWindow: this.parentActor.ignoreSubFrames,
+ }
+ );
+ this.consoleServiceListener.init();
+ }
+ startedListeners.push(event);
+ break;
+ case "ConsoleAPI":
+ if (!this.consoleAPIListener) {
+ // Create the consoleAPIListener
+ // (and apply the filtering options defined in the parent actor).
+ this.consoleAPIListener = new ConsoleAPIListener(
+ global,
+ this.onConsoleAPICall,
+ {
+ matchExactWindow: this.parentActor.ignoreSubFrames,
+ ...(this.parentActor.consoleAPIListenerOptions || {}),
+ }
+ );
+ this.consoleAPIListener.init();
+ }
+ startedListeners.push(event);
+ break;
+ case "NetworkActivity":
+ // Workers don't support this message type
+ if (isWorker) {
+ break;
+ }
+ // Bug 1807650 removed this in favor of the new Watcher/Resources APIs
+ const errorMessage =
+ "NetworkActivity is no longer supported. " +
+ "Instead use Watcher actor's watchResources and listen to NETWORK_EVENT resource";
+ dump(errorMessage + "\n");
+ throw new Error(errorMessage);
+ case "FileActivity":
+ // Workers don't support this message type
+ if (isWorker) {
+ break;
+ }
+ if (this.global instanceof Ci.nsIDOMWindow) {
+ if (!this.consoleFileActivityListener) {
+ this.consoleFileActivityListener =
+ new ConsoleFileActivityListener(this.global, this);
+ }
+ this.consoleFileActivityListener.startMonitor();
+ startedListeners.push(event);
+ }
+ break;
+ case "ReflowActivity":
+ // Workers don't support this message type
+ if (isWorker) {
+ break;
+ }
+ if (!this.consoleReflowListener) {
+ this.consoleReflowListener = new ConsoleReflowListener(
+ this.global,
+ this
+ );
+ }
+ startedListeners.push(event);
+ break;
+ case "DocumentEvents":
+ // Workers don't support this message type
+ if (isWorker || isTargetActorContentProcess) {
+ break;
+ }
+ if (!this.documentEventsListener) {
+ this.documentEventsListener = new DocumentEventsListener(
+ this.parentActor
+ );
+
+ this.documentEventsListener.on("dom-loading", data =>
+ this.onDocumentEvent("dom-loading", data)
+ );
+ this.documentEventsListener.on("dom-interactive", data =>
+ this.onDocumentEvent("dom-interactive", data)
+ );
+ this.documentEventsListener.on("dom-complete", data =>
+ this.onDocumentEvent("dom-complete", data)
+ );
+
+ this.documentEventsListener.listen();
+ }
+ startedListeners.push(event);
+ break;
+ }
+ }
+
+ // Update the live list of running listeners
+ startedListeners.forEach(this._listeners.add, this._listeners);
+
+ return {
+ startedListeners,
+ };
+ }
+
+ /**
+ * Handler for the "stopListeners" request.
+ *
+ * @param array listeners
+ * An array of events to stop sent by the Web Console client.
+ * @return object
+ * The response packet to send to the client: holds the
+ * stoppedListeners array.
+ */
+ stopListeners(listeners) {
+ const stoppedListeners = [];
+
+ // If no specific listeners are requested to be detached, we stop all
+ // listeners.
+ const eventsToDetach = listeners || [
+ "PageError",
+ "ConsoleAPI",
+ "FileActivity",
+ "ReflowActivity",
+ "DocumentEvents",
+ ];
+
+ for (const event of eventsToDetach) {
+ switch (event) {
+ case "PageError":
+ if (this.consoleServiceListener) {
+ this.consoleServiceListener.destroy();
+ this.consoleServiceListener = null;
+ }
+ stoppedListeners.push(event);
+ break;
+ case "ConsoleAPI":
+ if (this.consoleAPIListener) {
+ this.consoleAPIListener.destroy();
+ this.consoleAPIListener = null;
+ }
+ stoppedListeners.push(event);
+ break;
+ case "FileActivity":
+ if (this.consoleFileActivityListener) {
+ this.consoleFileActivityListener.stopMonitor();
+ this.consoleFileActivityListener = null;
+ }
+ stoppedListeners.push(event);
+ break;
+ case "ReflowActivity":
+ if (this.consoleReflowListener) {
+ this.consoleReflowListener.destroy();
+ this.consoleReflowListener = null;
+ }
+ stoppedListeners.push(event);
+ break;
+ case "DocumentEvents":
+ if (this.documentEventsListener) {
+ this.documentEventsListener.destroy();
+ this.documentEventsListener = null;
+ }
+ stoppedListeners.push(event);
+ break;
+ }
+ }
+
+ // Update the live list of running listeners
+ stoppedListeners.forEach(this._listeners.delete, this._listeners);
+
+ return { stoppedListeners };
+ }
+
+ /**
+ * Handler for the "getCachedMessages" request. This method sends the cached
+ * error messages and the window.console API calls to the client.
+ *
+ * @param array messageTypes
+ * An array of message types sent by the Web Console client.
+ * @return object
+ * The response packet to send to the client: it holds the cached
+ * messages array.
+ */
+ getCachedMessages(messageTypes) {
+ if (!messageTypes) {
+ return {
+ error: "missingParameter",
+ message: "The messageTypes parameter is missing.",
+ };
+ }
+
+ const messages = [];
+
+ const consoleServiceCachedMessages =
+ messageTypes.includes("PageError") || messageTypes.includes("LogMessage")
+ ? this.consoleServiceListener?.getCachedMessages(
+ !this.parentActor.isRootActor
+ )
+ : null;
+
+ for (const type of messageTypes) {
+ switch (type) {
+ case "ConsoleAPI": {
+ if (!this.consoleAPIListener) {
+ break;
+ }
+
+ // this.global might not be a window (can be a worker global or a Sandbox),
+ // and in such case performance isn't defined
+ const winStartTime =
+ this.global?.performance?.timing?.navigationStart;
+
+ const cache = this.consoleAPIListener.getCachedMessages(
+ !this.parentActor.isRootActor
+ );
+ cache.forEach(cachedMessage => {
+ // Filter out messages that came from a ServiceWorker but happened
+ // before the page was requested.
+ if (
+ cachedMessage.innerID === "ServiceWorker" &&
+ winStartTime > cachedMessage.timeStamp
+ ) {
+ return;
+ }
+
+ messages.push({
+ message: this.prepareConsoleMessageForRemote(cachedMessage),
+ type: "consoleAPICall",
+ });
+ });
+ break;
+ }
+
+ case "PageError": {
+ if (!consoleServiceCachedMessages) {
+ break;
+ }
+
+ for (const cachedMessage of consoleServiceCachedMessages) {
+ if (!(cachedMessage instanceof Ci.nsIScriptError)) {
+ continue;
+ }
+
+ messages.push({
+ pageError: this.preparePageErrorForRemote(cachedMessage),
+ type: "pageError",
+ });
+ }
+ break;
+ }
+
+ case "LogMessage": {
+ if (!consoleServiceCachedMessages) {
+ break;
+ }
+
+ for (const cachedMessage of consoleServiceCachedMessages) {
+ if (cachedMessage instanceof Ci.nsIScriptError) {
+ continue;
+ }
+
+ messages.push({
+ message: this._createStringGrip(cachedMessage.message),
+ timeStamp: cachedMessage.microSecondTimeStamp / 1000,
+ type: "logMessage",
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ return {
+ messages,
+ };
+ }
+
+ /**
+ * Handler for the "evaluateJSAsync" request. This method evaluates a given
+ * JavaScript string with an associated `resultID`.
+ *
+ * The result will be returned later as an unsolicited `evaluationResult`,
+ * that can be associated back to this request via the `resultID` field.
+ *
+ * @param object request
+ * The JSON request object received from the Web Console client.
+ * @return object
+ * The response packet to send to with the unique id in the
+ * `resultID` field.
+ */
+ async evaluateJSAsync(request) {
+ const startTime = ChromeUtils.dateNow();
+ // Use a timestamp instead of a UUID as this code is used by workers, which
+ // don't have access to the UUID XPCOM component.
+ // Also use a counter in order to prevent mixing up response when calling
+ // at the exact same time.
+ const resultID = startTime + "-" + this._evalCounter++;
+
+ // Execute the evaluation in the next event loop in order to immediately
+ // reply with the resultID.
+ //
+ // The console input should be evaluated with micro task level != 0,
+ // so that microtask checkpoint isn't performed while evaluating it.
+ DevToolsUtils.executeSoonWithMicroTask(async () => {
+ try {
+ // Execute the script that may pause.
+ let response = await this.evaluateJS(request);
+ // Wait for any potential returned Promise.
+ response = await this._maybeWaitForResponseResult(response);
+
+ // Set the timestamp only now, so any messages logged in the expression (e.g. console.log)
+ // can be appended before the result message (unlike the evaluation result, other
+ // console resources are throttled before being handled by the webconsole client,
+ // which might cause some ordering issue).
+ // Use ChromeUtils.dateNow() as it gives us a higher precision than Date.now().
+ response.timestamp = ChromeUtils.dateNow();
+ // Finally, emit an unsolicited evaluationResult packet with the evaluation result.
+ this.emit("evaluationResult", {
+ type: "evaluationResult",
+ resultID,
+ startTime,
+ ...response,
+ });
+ } catch (e) {
+ const message = `Encountered error while waiting for Helper Result: ${e}\n${e.stack}`;
+ DevToolsUtils.reportException("evaluateJSAsync", Error(message));
+ }
+ });
+ return { resultID };
+ }
+
+ /**
+ * In order to support async evaluations (e.g. top-level await, …),
+ * we have to be able to handle promises. This method handles waiting for the promise,
+ * and then returns the result.
+ *
+ * @private
+ * @param object response
+ * The response packet to send to with the unique id in the
+ * `resultID` field, and potentially a promise in the `helperResult` or in the
+ * `awaitResult` field.
+ *
+ * @return object
+ * The updated response object.
+ */
+ async _maybeWaitForResponseResult(response) {
+ if (!response?.awaitResult) {
+ return response;
+ }
+
+ let result;
+ try {
+ result = await response.awaitResult;
+
+ // `createValueGrip` expect a debuggee value, while here we have the raw object.
+ // We need to call `makeDebuggeeValue` on it to make it work.
+ const dbgResult = this.makeDebuggeeValue(result);
+ response.result = this.createValueGrip(dbgResult);
+ } catch (e) {
+ // The promise was rejected. We let the engine handle this as it will report a
+ // `uncaught exception` error.
+ response.topLevelAwaitRejected = true;
+ }
+
+ // Remove the promise from the response object.
+ delete response.awaitResult;
+
+ return response;
+ }
+
+ /**
+ * Handler for the "evaluateJS" request. This method evaluates the given
+ * JavaScript string and sends back the result.
+ *
+ * @param object request
+ * The JSON request object received from the Web Console client.
+ * @return object
+ * The evaluation response packet.
+ */
+ evaluateJS(request) {
+ const input = request.text;
+
+ const evalOptions = {
+ frameActor: request.frameActor,
+ url: request.url,
+ innerWindowID: request.innerWindowID,
+ selectedNodeActor: request.selectedNodeActor,
+ selectedObjectActor: request.selectedObjectActor,
+ eager: request.eager,
+ bindings: request.bindings,
+ lineNumber: request.lineNumber,
+ // This flag is set to true in most cases as we consider most evaluations as internal and:
+ // * prevent any breakpoint from being triggerred when evaluating the JS input
+ // * prevent spawning Debugger.Source for the evaluated JS and showing it in Debugger UI
+ // This is only set to false when evaluating the console input.
+ disableBreaks: !!request.disableBreaks,
+ // Optional flag, to be set to true when Console Commands should override local symbols with
+ // the same name. Like if the page defines `$`, the evaluated string will use the `$` implemented
+ // by the console command instead of the page's function.
+ preferConsoleCommandsOverLocalSymbols:
+ !!request.preferConsoleCommandsOverLocalSymbols,
+ };
+
+ const { mapped } = request;
+
+ // Set a flag on the thread actor which indicates an evaluation is being
+ // done for the client. This is used to disable all types of breakpoints for all sources
+ // via `disabledBreaks`. When this flag is used, `reportExceptionsWhenBreaksAreDisabled`
+ // allows to still pause on exceptions.
+ this.parentActor.threadActor.insideClientEvaluation = evalOptions;
+
+ let evalInfo;
+ try {
+ evalInfo = evalWithDebugger(input, evalOptions, this);
+ } finally {
+ this.parentActor.threadActor.insideClientEvaluation = null;
+ }
+
+ return new Promise((resolve, reject) => {
+ // Queue up a task to run in the next tick so any microtask created by the evaluated
+ // expression has the time to be run.
+ // e.g. in :
+ // ```
+ // const promiseThenCb = result => "result: " + result;
+ // new Promise(res => res("hello")).then(promiseThenCb)
+ // ```
+ // we want`promiseThenCb` to have run before handling the result.
+ DevToolsUtils.executeSoon(() => {
+ try {
+ const result = this.prepareEvaluationResult(
+ evalInfo,
+ input,
+ request.eager,
+ mapped
+ );
+ resolve(result);
+ } catch (err) {
+ reject(err);
+ }
+ });
+ });
+ }
+
+ // eslint-disable-next-line complexity
+ prepareEvaluationResult(evalInfo, input, eager, mapped) {
+ const evalResult = evalInfo.result;
+ const helperResult = evalInfo.helperResult;
+
+ let result,
+ errorDocURL,
+ errorMessage,
+ errorNotes = null,
+ errorGrip = null,
+ frame = null,
+ awaitResult,
+ errorMessageName,
+ exceptionStack;
+ if (evalResult) {
+ if ("return" in evalResult) {
+ result = evalResult.return;
+ if (
+ mapped?.await &&
+ result &&
+ result.class === "Promise" &&
+ typeof result.unsafeDereference === "function"
+ ) {
+ awaitResult = result.unsafeDereference();
+ }
+ } else if ("yield" in evalResult) {
+ result = evalResult.yield;
+ } else if ("throw" in evalResult) {
+ const error = evalResult.throw;
+ errorGrip = this.createValueGrip(error);
+
+ exceptionStack = this.prepareStackForRemote(evalResult.stack);
+
+ if (exceptionStack) {
+ // Set the frame based on the topmost stack frame for the exception.
+ const {
+ filename: source,
+ sourceId,
+ lineNumber: line,
+ columnNumber: column,
+ } = exceptionStack[0];
+ frame = { source, sourceId, line, column };
+
+ exceptionStack =
+ WebConsoleUtils.removeFramesAboveDebuggerEval(exceptionStack);
+ }
+
+ errorMessage = String(error);
+ if (typeof error === "object" && error !== null) {
+ try {
+ errorMessage = DevToolsUtils.callPropertyOnObject(
+ error,
+ "toString"
+ );
+ } catch (e) {
+ // If the debuggee is not allowed to access the "toString" property
+ // of the error object, calling this property from the debuggee's
+ // compartment will fail. The debugger should show the error object
+ // as it is seen by the debuggee, so this behavior is correct.
+ //
+ // Unfortunately, we have at least one test that assumes calling the
+ // "toString" property of an error object will succeed if the
+ // debugger is allowed to access it, regardless of whether the
+ // debuggee is allowed to access it or not.
+ //
+ // To accomodate these tests, if calling the "toString" property
+ // from the debuggee compartment fails, we rewrap the error object
+ // in the debugger's compartment, and then call the "toString"
+ // property from there.
+ if (typeof error.unsafeDereference === "function") {
+ const rawError = error.unsafeDereference();
+ errorMessage = rawError ? rawError.toString() : "";
+ }
+ }
+ }
+
+ // It is possible that we won't have permission to unwrap an
+ // object and retrieve its errorMessageName.
+ try {
+ errorDocURL = ErrorDocs.GetURL(error);
+ errorMessageName = error.errorMessageName;
+ } catch (ex) {
+ // ignored
+ }
+
+ try {
+ const line = error.errorLineNumber;
+ const column = error.errorColumnNumber;
+
+ if (typeof line === "number" && typeof column === "number") {
+ // Set frame only if we have line/column numbers.
+ frame = {
+ source: "debugger eval code",
+ line,
+ column,
+ };
+ }
+ } catch (ex) {
+ // ignored
+ }
+
+ try {
+ const notes = error.errorNotes;
+ if (notes?.length) {
+ errorNotes = [];
+ for (const note of notes) {
+ errorNotes.push({
+ messageBody: this._createStringGrip(note.message),
+ frame: {
+ source: note.fileName,
+ line: note.lineNumber,
+ column: note.columnNumber,
+ },
+ });
+ }
+ }
+ } catch (ex) {
+ // ignored
+ }
+ }
+ }
+
+ // If a value is encountered that the devtools server doesn't support yet,
+ // the console should remain functional.
+ let resultGrip;
+ if (!awaitResult) {
+ try {
+ const objectActor =
+ this.parentActor.threadActor.getThreadLifetimeObject(result);
+ if (objectActor) {
+ resultGrip = this.parentActor.threadActor.createValueGrip(result);
+ } else {
+ resultGrip = this.createValueGrip(result);
+ }
+ } catch (e) {
+ errorMessage = e;
+ }
+ }
+
+ // Don't update _lastConsoleInputEvaluation in eager evaluation, as it would interfere
+ // with the $_ command.
+ if (!eager) {
+ if (!awaitResult) {
+ this._lastConsoleInputEvaluation = result;
+ } else {
+ // If we evaluated a top-level await expression, we want to assign its result to the
+ // _lastConsoleInputEvaluation only when the promise resolves, and only if it
+ // resolves. If the promise rejects, we don't re-assign _lastConsoleInputEvaluation,
+ // it will keep its previous value.
+
+ const p = awaitResult.then(res => {
+ this._lastConsoleInputEvaluation = this.makeDebuggeeValue(res);
+ });
+
+ // If the top level await was already rejected (e.g. `await Promise.reject("bleh")`),
+ // catch the resulting promise of awaitResult.then.
+ // If we don't do that, the new Promise will also be rejected, and since it's
+ // unhandled, it will generate an error.
+ // We don't want to do that for pending promise (e.g. `await new Promise((res, rej) => setTimeout(rej,250))`),
+ // as the the Promise rejection will be considered as handled, and the "Uncaught (in promise)"
+ // message wouldn't be emitted.
+ const { state } = ObjectUtils.getPromiseState(evalResult.return);
+ if (state === "rejected") {
+ p.catch(() => {});
+ }
+ }
+ }
+
+ return {
+ input,
+ result: resultGrip,
+ awaitResult,
+ exception: errorGrip,
+ exceptionMessage: this._createStringGrip(errorMessage),
+ exceptionDocURL: errorDocURL,
+ exceptionStack,
+ hasException: errorGrip !== null,
+ errorMessageName,
+ frame,
+ helperResult,
+ notes: errorNotes,
+ };
+ }
+
+ /**
+ * The Autocomplete request handler.
+ *
+ * @param string text
+ * The request message - what input to autocomplete.
+ * @param number cursor
+ * The cursor position at the moment of starting autocomplete.
+ * @param string frameActor
+ * The frameactor id of the current paused frame.
+ * @param string selectedNodeActor
+ * The actor id of the currently selected node.
+ * @param array authorizedEvaluations
+ * Array of the properties access which can be executed by the engine.
+ * @return object
+ * The response message - matched properties.
+ */
+ autocomplete(
+ text,
+ cursor,
+ frameActorId,
+ selectedNodeActor,
+ authorizedEvaluations,
+ expressionVars = []
+ ) {
+ let dbgObject = null;
+ let environment = null;
+ let matches = [];
+ let matchProp;
+ let isElementAccess;
+
+ const reqText = text.substr(0, cursor);
+
+ if (isCommand(reqText)) {
+ matchProp = reqText;
+ matches = WebConsoleCommandsManager.getAllColonCommandNames()
+ .filter(c => `:${c}`.startsWith(reqText))
+ .map(c => `:${c}`);
+ } else {
+ // This is the case of the paused debugger
+ if (frameActorId) {
+ const frameActor = this.conn.getActor(frameActorId);
+ try {
+ // Need to try/catch since accessing frame.environment
+ // can throw "Debugger.Frame is not live"
+ const frame = frameActor.frame;
+ environment = frame.environment;
+ } catch (e) {
+ DevToolsUtils.reportException(
+ "autocomplete",
+ Error("The frame actor was not found: " + frameActorId)
+ );
+ }
+ } else {
+ dbgObject = this.dbg.addDebuggee(this.evalGlobal);
+ }
+
+ const result = jsPropertyProvider({
+ dbgObject,
+ environment,
+ frameActorId,
+ inputValue: text,
+ cursor,
+ webconsoleActor: this,
+ selectedNodeActor,
+ authorizedEvaluations,
+ expressionVars,
+ });
+
+ if (result === null) {
+ return {
+ matches: null,
+ };
+ }
+
+ if (result && result.isUnsafeGetter === true) {
+ return {
+ isUnsafeGetter: true,
+ getterPath: result.getterPath,
+ };
+ }
+
+ matches = result.matches || new Set();
+ matchProp = result.matchProp || "";
+ isElementAccess = result.isElementAccess;
+
+ // We consider '$' as alphanumeric because it is used in the names of some
+ // helper functions; we also consider whitespace as alphanum since it should not
+ // be seen as break in the evaled string.
+ const lastNonAlphaIsDot = /[.][a-zA-Z0-9$\s]*$/.test(reqText);
+
+ // We only return commands and keywords when we are not dealing with a property or
+ // element access.
+ if (matchProp && !lastNonAlphaIsDot && !isElementAccess) {
+ const colonOnlyCommands =
+ WebConsoleCommandsManager.getColonOnlyCommandNames();
+ for (const name of WebConsoleCommandsManager.getAllCommandNames()) {
+ // Filter out commands like `screenshot` as it is inaccessible without the `:` prefix
+ if (
+ !colonOnlyCommands.includes(name) &&
+ name.startsWith(result.matchProp)
+ ) {
+ matches.add(name);
+ }
+ }
+
+ for (const keyword of RESERVED_JS_KEYWORDS) {
+ if (keyword.startsWith(result.matchProp)) {
+ matches.add(keyword);
+ }
+ }
+ }
+
+ // Sort the results in order to display lowercased item first (e.g. we want to
+ // display `document` then `Document` as we loosely match the user input if the
+ // first letter was lowercase).
+ const firstMeaningfulCharIndex = isElementAccess ? 1 : 0;
+ matches = Array.from(matches).sort((a, b) => {
+ const aFirstMeaningfulChar = a[firstMeaningfulCharIndex];
+ const bFirstMeaningfulChar = b[firstMeaningfulCharIndex];
+ const lA =
+ aFirstMeaningfulChar.toLocaleLowerCase() === aFirstMeaningfulChar;
+ const lB =
+ bFirstMeaningfulChar.toLocaleLowerCase() === bFirstMeaningfulChar;
+ if (lA === lB) {
+ if (a === matchProp) {
+ return -1;
+ }
+ if (b === matchProp) {
+ return 1;
+ }
+ return a.localeCompare(b);
+ }
+ return lA ? -1 : 1;
+ });
+ }
+
+ return {
+ matches,
+ matchProp,
+ isElementAccess: isElementAccess === true,
+ };
+ }
+
+ /**
+ * The "clearMessagesCacheAsync" request handler.
+ */
+ clearMessagesCacheAsync() {
+ if (isWorker) {
+ // Defined on WorkerScope
+ clearConsoleEvents();
+ return;
+ }
+
+ const windowId = !this.parentActor.isRootActor
+ ? WebConsoleUtils.getInnerWindowId(this.global)
+ : null;
+
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+ ConsoleAPIStorage.clearEvents(windowId);
+
+ CONSOLE_WORKER_IDS.forEach(id => {
+ ConsoleAPIStorage.clearEvents(id);
+ });
+
+ if (this.parentActor.isRootActor || !this.global) {
+ // If were dealing with the root actor (e.g. the browser console), we want
+ // to remove all cached messages, not only the ones specific to a window.
+ Services.console.reset();
+ } else if (this.parentActor.ignoreSubFrames) {
+ Services.console.resetWindow(windowId);
+ } else {
+ WebConsoleUtils.getInnerWindowIDsForFrames(this.global).forEach(id =>
+ Services.console.resetWindow(id)
+ );
+ }
+ }
+
+ // End of request handlers.
+
+ // Event handlers for various listeners.
+
+ /**
+ * Handler for messages received from the ConsoleServiceListener. This method
+ * sends the nsIConsoleMessage to the remote Web Console client.
+ *
+ * @param nsIConsoleMessage message
+ * The message we need to send to the client.
+ */
+ onConsoleServiceMessage(message) {
+ if (message instanceof Ci.nsIScriptError) {
+ this.emit("pageError", {
+ pageError: this.preparePageErrorForRemote(message),
+ });
+ } else {
+ this.emit("logMessage", {
+ message: this._createStringGrip(message.message),
+ timeStamp: message.microSecondTimeStamp / 1000,
+ });
+ }
+ }
+
+ getActorIdForInternalSourceId(id) {
+ const actor =
+ this.parentActor.sourcesManager.getSourceActorByInternalSourceId(id);
+ return actor ? actor.actorID : null;
+ }
+
+ /**
+ * Prepare a SavedFrame stack to be sent to the client.
+ *
+ * @param SavedFrame errorStack
+ * Stack for an error we need to send to the client.
+ * @return object
+ * The object you can send to the remote client.
+ */
+ prepareStackForRemote(errorStack) {
+ // Convert stack objects to the JSON attributes expected by client code
+ // Bug 1348885: If the global from which this error came from has been
+ // nuked, stack is going to be a dead wrapper.
+ if (!errorStack || (Cu && Cu.isDeadWrapper(errorStack))) {
+ return null;
+ }
+ const stack = [];
+ let s = errorStack;
+ while (s) {
+ stack.push({
+ filename: s.source,
+ sourceId: this.getActorIdForInternalSourceId(s.sourceId),
+ lineNumber: s.line,
+ columnNumber: s.column,
+ functionName: s.functionDisplayName,
+ asyncCause: s.asyncCause ? s.asyncCause : undefined,
+ });
+ s = s.parent || s.asyncParent;
+ }
+ return stack;
+ }
+
+ /**
+ * Prepare an nsIScriptError to be sent to the client.
+ *
+ * @param nsIScriptError pageError
+ * The page error we need to send to the client.
+ * @return object
+ * The object you can send to the remote client.
+ */
+ preparePageErrorForRemote(pageError) {
+ const stack = this.prepareStackForRemote(pageError.stack);
+ let lineText = pageError.sourceLine;
+ if (
+ lineText &&
+ lineText.length > DevToolsServer.LONG_STRING_INITIAL_LENGTH
+ ) {
+ lineText = lineText.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH);
+ }
+
+ let notesArray = null;
+ const notes = pageError.notes;
+ if (notes?.length) {
+ notesArray = [];
+ for (let i = 0, len = notes.length; i < len; i++) {
+ const note = notes.queryElementAt(i, Ci.nsIScriptErrorNote);
+ notesArray.push({
+ messageBody: this._createStringGrip(note.errorMessage),
+ frame: {
+ source: note.sourceName,
+ sourceId: this.getActorIdForInternalSourceId(note.sourceId),
+ line: note.lineNumber,
+ column: note.columnNumber,
+ },
+ });
+ }
+ }
+
+ // If there is no location information in the error but we have a stack,
+ // fill in the location with the first frame on the stack.
+ let { sourceName, sourceId, lineNumber, columnNumber } = pageError;
+ if (!sourceName && !sourceId && !lineNumber && !columnNumber && stack) {
+ sourceName = stack[0].filename;
+ sourceId = stack[0].sourceId;
+ lineNumber = stack[0].lineNumber;
+ columnNumber = stack[0].columnNumber;
+ }
+
+ const isCSSMessage = pageError.category === MESSAGE_CATEGORY.CSS_PARSER;
+
+ const result = {
+ errorMessage: this._createStringGrip(pageError.errorMessage),
+ errorMessageName: isCSSMessage ? undefined : pageError.errorMessageName,
+ exceptionDocURL: ErrorDocs.GetURL(pageError),
+ sourceName,
+ sourceId: this.getActorIdForInternalSourceId(sourceId),
+ lineText,
+ lineNumber,
+ columnNumber,
+ category: pageError.category,
+ innerWindowID: pageError.innerWindowID,
+ timeStamp: pageError.microSecondTimeStamp / 1000,
+ warning: !!(pageError.flags & pageError.warningFlag),
+ error: !(pageError.flags & (pageError.warningFlag | pageError.infoFlag)),
+ info: !!(pageError.flags & pageError.infoFlag),
+ private: pageError.isFromPrivateWindow,
+ stacktrace: stack,
+ notes: notesArray,
+ chromeContext: pageError.isFromChromeContext,
+ isPromiseRejection: isCSSMessage
+ ? undefined
+ : pageError.isPromiseRejection,
+ isForwardedFromContentProcess: pageError.isForwardedFromContentProcess,
+ cssSelectors: isCSSMessage ? pageError.cssSelectors : undefined,
+ };
+
+ // If the pageError does have an exception object, we want to return the grip for it,
+ // but only if we do manage to get the grip, as we're checking the property on the
+ // client to render things differently.
+ if (pageError.hasException) {
+ try {
+ const obj = this.makeDebuggeeValue(pageError.exception, true);
+ if (obj?.class !== "DeadObject") {
+ result.exception = this.createValueGrip(obj);
+ result.hasException = true;
+ }
+ } catch (e) {}
+ }
+
+ return result;
+ }
+
+ /**
+ * Handler for window.console API calls received from the ConsoleAPIListener.
+ * This method sends the object to the remote Web Console client.
+ *
+ * @see ConsoleAPIListener
+ * @param object message
+ * The console API call we need to send to the remote client.
+ * @param object extraProperties
+ * an object whose properties will be folded in the packet that is emitted.
+ */
+ onConsoleAPICall(message, extraProperties = {}) {
+ this.emit("consoleAPICall", {
+ message: this.prepareConsoleMessageForRemote(message),
+ ...extraProperties,
+ });
+ }
+
+ /**
+ * Handler for the DocumentEventsListener.
+ *
+ * @see DocumentEventsListener
+ * @param {String} name
+ * The document event name that either of followings.
+ * - dom-loading
+ * - dom-interactive
+ * - dom-complete
+ * @param {Number} time
+ * The time that the event is fired.
+ * @param {Boolean} hasNativeConsoleAPI
+ * Tells if the window.console object is native or overwritten by script in the page.
+ * Only passed when `name` is "dom-complete" (see devtools/server/actors/webconsole/listeners/document-events.js).
+ */
+ onDocumentEvent(name, { time, hasNativeConsoleAPI }) {
+ this.emit("documentEvent", {
+ name,
+ time,
+ hasNativeConsoleAPI,
+ });
+ }
+
+ /**
+ * Handler for file activity. This method sends the file request information
+ * to the remote Web Console client.
+ *
+ * @see ConsoleFileActivityListener
+ * @param string fileURI
+ * The requested file URI.
+ */
+ onFileActivity(fileURI) {
+ this.emit("fileActivity", {
+ uri: fileURI,
+ });
+ }
+
+ // End of event handlers for various listeners.
+
+ /**
+ * Prepare a message from the console API to be sent to the remote Web Console
+ * instance.
+ *
+ * @param object message
+ * The original message received from the console storage listener.
+ * @param boolean aUseObjectGlobal
+ * If |true| the object global is determined and added as a debuggee,
+ * otherwise |this.global| is used when makeDebuggeeValue() is invoked.
+ * @return object
+ * The object that can be sent to the remote client.
+ */
+ prepareConsoleMessageForRemote(message, useObjectGlobal = true) {
+ const result = {
+ arguments: message.arguments
+ ? message.arguments.map(obj => {
+ const dbgObj = this.makeDebuggeeValue(obj, useObjectGlobal);
+ return this.createValueGrip(dbgObj);
+ })
+ : [],
+ chromeContext: message.chromeContext,
+ columnNumber: message.columnNumber,
+ filename: message.filename,
+ level: message.level,
+ lineNumber: message.lineNumber,
+ // messages emitted from Console.sys.mjs don't have a microSecondTimeStamp property
+ timeStamp: message.microSecondTimeStamp
+ ? message.microSecondTimeStamp / 1000
+ : message.timeStamp,
+ sourceId: this.getActorIdForInternalSourceId(message.sourceId),
+ category: message.category || "webdev",
+ innerWindowID: message.innerID,
+ };
+
+ // It only make sense to include the following properties in the message when they have
+ // a meaningful value. Otherwise we simply don't include them so we save cycles in JSActor communication.
+ if (message.counter) {
+ result.counter = message.counter;
+ }
+ if (message.private) {
+ result.private = message.private;
+ }
+ if (message.prefix) {
+ result.prefix = message.prefix;
+ }
+
+ if (message.stacktrace) {
+ result.stacktrace = message.stacktrace.map(frame => {
+ return {
+ ...frame,
+ sourceId: this.getActorIdForInternalSourceId(frame.sourceId),
+ };
+ });
+ }
+
+ if (message.styles && message.styles.length) {
+ result.styles = message.styles.map(string => {
+ return this.createValueGrip(string);
+ });
+ }
+
+ if (message.timer) {
+ result.timer = message.timer;
+ }
+
+ if (message.level === "table") {
+ const tableItems = this._getConsoleTableMessageItems(result);
+ if (tableItems) {
+ result.arguments[0].ownProperties = tableItems;
+ result.arguments[0].preview = null;
+ }
+
+ // Only return the 2 first params.
+ result.arguments = result.arguments.slice(0, 2);
+ }
+
+ return result;
+ }
+
+ /**
+ * Return the properties needed to display the appropriate table for a given
+ * console.table call.
+ * This function does a little more than creating an ObjectActor for the first
+ * parameter of the message. When layout out the console table in the output, we want
+ * to be able to look into sub-properties so the table can have a different layout (
+ * for arrays of arrays, objects with objects properties, arrays of objects, …).
+ * So here we need to retrieve the properties of the first parameter, and also all the
+ * sub-properties we might need.
+ *
+ * @param {Object} result: The console.table message.
+ * @returns {Object} An object containing the properties of the first argument of the
+ * console.table call.
+ */
+ _getConsoleTableMessageItems(result) {
+ if (
+ !result ||
+ !Array.isArray(result.arguments) ||
+ !result.arguments.length
+ ) {
+ return null;
+ }
+
+ const [tableItemGrip] = result.arguments;
+ const dataType = tableItemGrip.class;
+ const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType);
+ const ignoreNonIndexedProperties = isArray(tableItemGrip);
+
+ const tableItemActor = this.getActorByID(tableItemGrip.actor);
+ if (!tableItemActor) {
+ return null;
+ }
+
+ // Retrieve the properties (or entries for Set/Map) of the console table first arg.
+ const iterator = needEntries
+ ? tableItemActor.enumEntries()
+ : tableItemActor.enumProperties({
+ ignoreNonIndexedProperties,
+ });
+ const { ownProperties } = iterator.all();
+
+ // The iterator returns a descriptor for each property, wherein the value could be
+ // in one of those sub-property.
+ const descriptorKeys = ["safeGetterValues", "getterValue", "value"];
+
+ Object.values(ownProperties).forEach(desc => {
+ if (typeof desc !== "undefined") {
+ descriptorKeys.forEach(key => {
+ if (desc && desc.hasOwnProperty(key)) {
+ const grip = desc[key];
+
+ // We need to load sub-properties as well to render the table in a nice way.
+ const actor = grip && this.getActorByID(grip.actor);
+ if (actor) {
+ const res = actor
+ .enumProperties({
+ ignoreNonIndexedProperties: isArray(grip),
+ })
+ .all();
+ if (res?.ownProperties) {
+ desc[key].ownProperties = res.ownProperties;
+ }
+ }
+ }
+ });
+ }
+ });
+
+ return ownProperties;
+ }
+
+ /**
+ * The "will-navigate" progress listener. This is used to clear the current
+ * eval scope.
+ */
+ _onWillNavigate({ window, isTopLevel }) {
+ if (isTopLevel) {
+ this._evalGlobal = null;
+ EventEmitter.off(this.parentActor, "will-navigate", this._onWillNavigate);
+ this._progressListenerActive = false;
+ }
+ }
+
+ /**
+ * This listener is called when we switch to another frame,
+ * mostly to unregister previous listeners and start listening on the new document.
+ */
+ _onChangedToplevelDocument() {
+ // Convert the Set to an Array
+ const listeners = [...this._listeners];
+
+ // Unregister existing listener on the previous document
+ // (pass a copy of the array as it will shift from it)
+ this.stopListeners(listeners.slice());
+
+ // This method is called after this.global is changed,
+ // so we register new listener on this new global
+ this.startListeners(listeners);
+
+ // Also reset the cached top level chrome window being targeted
+ this._lastChromeWindow = null;
+ }
+}
+
+exports.WebConsoleActor = WebConsoleActor;
diff --git a/devtools/server/actors/webconsole/commands/experimental-commands.ftl b/devtools/server/actors/webconsole/commands/experimental-commands.ftl
new file mode 100644
index 0000000000..b11c29006b
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands/experimental-commands.ftl
@@ -0,0 +1,21 @@
+# 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/.
+
+# These strings aren't translated and are meant to be used for experimental commands
+# which may frequently update their documentations
+
+# Usage string for :trace command
+webconsole-commands-usage-trace3 =
+ :trace
+
+ Toggles the JavaScript tracer
+
+ It supports the following arguments:
+ --logMethod to be set to ‘console’ for logging to the web console (the default), or ‘stdout’ for logging to the standard output,
+ --values Optional flag to be passed to log function call arguments as well as returned values (when returned frames are enabled).
+ --on-next-interaction Optional flag, when set, the tracer will only start on next mousedown or keydown event.
+ --max-depth Optional flag, will restrict logging trace to a given depth passed as argument.
+ --max-records Optional flag, will automatically stop the tracer after having logged the passed amount of top level frames.
+ --prefix Optional string which will be logged in front of all the trace logs,
+ --help or --usage to show this message.
diff --git a/devtools/server/actors/webconsole/commands/manager.js b/devtools/server/actors/webconsole/commands/manager.js
new file mode 100644
index 0000000000..538ee7eac1
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands/manager.js
@@ -0,0 +1,901 @@
+/* This Smurce 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,
+ ["getCommandAndArgs"],
+ "resource://devtools/server/actors/webconsole/commands/parser.js",
+ true
+);
+
+loader.lazyGetter(this, "l10n", () => {
+ return new Localization(
+ [
+ "devtools/shared/webconsole-commands.ftl",
+ "devtools/server/actors/webconsole/commands/experimental-commands.ftl",
+ ],
+ true
+ );
+});
+const USAGE_STRING_MAPPING = {
+ block: "webconsole-commands-usage-block",
+ trace: "webconsole-commands-usage-trace3",
+ unblock: "webconsole-commands-usage-unblock",
+};
+
+/**
+ * WebConsole commands manager.
+ *
+ * Defines a set of functions / variables ("commands") that are available from
+ * the Web Console but not from the web page.
+ *
+ */
+const WebConsoleCommandsManager = {
+ // Flag used by eager evaluation in order to allow the execution of commands
+ // which are side effect free and disallow all the others.
+ SIDE_EFFECT_FREE: Symbol("SIDE_EFFECT_FREE"),
+
+ // Map of command name to command function or property descriptor (see register method)
+ _registeredCommands: new Map(),
+ // Map of command name to optional array of accepted argument names
+ _validArguments: new Map(),
+ // Set of command names that are side effect free
+ _sideEffectFreeCommands: new Set(),
+
+ /**
+ * Register a new command.
+ *
+ * @param {Object} options
+ * @param {string} options.name
+ * The command name (exemple: "$", "screenshot",...))
+ * @param {Boolean} isSideEffectFree
+ * Tells if the command is free of any side effect to know
+ * if it can run in eager console evaluation.
+ * @param {function|object} options.command
+ * The command to register.
+ * It can be:
+ * - a function for the command like "$()" or ":screenshot"
+ * which triggers some code.
+ * - a property descriptor for getters like "$0",
+ * which only returns a value.
+ * @param {Array<string>} options.validArguments
+ * Optional list of valid arguments.
+ * If passed, we will assert that passed arguments are all valid on execution.
+ *
+ * The command function or the command getter are passed a:
+ * - "owner" object as their first parameter (see the example below).
+ * See _createOwnerObject for definition.
+ * - "args" object with all parameters when this is ran as a ":my-command" command.
+ * See getCommandAndArgs for definition.
+ *
+ * Note that if you want to support `--help` argument, you need to provide a usage string in:
+ * devtools/shared/locales/en-US/webconsole-commands.properties
+ *
+ * @example
+ *
+ * WebConsoleCommandsManager.register("$", function (owner, selector)
+ * {
+ * return owner.window.document.querySelector(selector);
+ * },
+ * ["my-argument"]);
+ *
+ * WebConsoleCommandsManager.register("$0", {
+ * get: function(owner) {
+ * return owner.makeDebuggeeValue(owner.selectedNode);
+ * }
+ * });
+ */
+ register({ name, isSideEffectFree, command, validArguments, usage }) {
+ if (
+ typeof command != "function" &&
+ !(typeof command == "object" && typeof command.get == "function")
+ ) {
+ throw new Error(
+ "Invalid web console command. It can only be a function, or an object with a function as 'get' attribute"
+ );
+ }
+ if (typeof isSideEffectFree !== "boolean") {
+ throw new Error(
+ "Invalid web console command. 'isSideEffectFree' attribute should be set and be a boolean"
+ );
+ }
+ this._registeredCommands.set(name, command);
+ if (validArguments) {
+ this._validArguments.set(name, validArguments);
+ }
+ if (isSideEffectFree) {
+ this._sideEffectFreeCommands.add(name);
+ }
+ },
+
+ /**
+ * Return the name of all registered commands.
+ *
+ * @return {array} List of all command names.
+ */
+ getAllCommandNames() {
+ return [...this._registeredCommands.keys()];
+ },
+
+ /**
+ * There is two types of "commands" here.
+ *
+ * - Functions or variables exposed in the scope of the evaluated string from the WebConsole input.
+ * Example: $(), $0, copy(), clear(),...
+ * - "True commands", which can also be ran from the WebConsole input with ":" prefix.
+ * Example: this list of commands.
+ * Note that some "true commands" are not exposed as function (see getColonOnlyCommandNames).
+ *
+ * The following list distinguish these "true commands" from the first category.
+ * It especially avoid any JavaScript evaluation when the frontend tries to execute
+ * a string starting with ':' character.
+ */
+ getAllColonCommandNames() {
+ return ["block", "help", "history", "screenshot", "unblock", "trace"];
+ },
+
+ /**
+ * Some commands are not exposed in the scope of the evaluated string,
+ * and can only be used via `:command-name`.
+ */
+ getColonOnlyCommandNames() {
+ return ["screenshot", "trace"];
+ },
+
+ /**
+ * Map of all command objects keyed by command name.
+ * Commands object are the objects passed to register() method.
+ *
+ * @return {Map<string -> command>}
+ */
+ getAllCommands() {
+ return this._registeredCommands;
+ },
+
+ /**
+ * Is the command name possibly overriding a symbol which
+ * already exists in the paused frame or the global into which
+ * we are about to execute into?
+ */
+ _isCommandNameAlreadyInScope(name, frame, dbgGlobal) {
+ if (frame && frame.environment) {
+ return !!frame.environment.find(name);
+ }
+
+ // Fallback on global scope when Debugger.Frame doesn't come along an
+ // Environment, or is not a frame.
+
+ try {
+ // This can throw in Browser Toolbox tests
+ const globalEnv = dbgGlobal.asEnvironment();
+ if (globalEnv) {
+ return !!dbgGlobal.asEnvironment().find(name);
+ }
+ } catch {}
+
+ return !!dbgGlobal.getOwnPropertyDescriptor(name);
+ },
+
+ _createOwnerObject(
+ consoleActor,
+ debuggerGlobal,
+ evalInput,
+ selectedNodeActorID
+ ) {
+ const owner = {
+ window: consoleActor.evalGlobal,
+ makeDebuggeeValue: debuggerGlobal.makeDebuggeeValue.bind(debuggerGlobal),
+ createValueGrip: consoleActor.createValueGrip.bind(consoleActor),
+ preprocessDebuggerObject:
+ consoleActor.preprocessDebuggerObject.bind(consoleActor),
+ helperResult: null,
+ consoleActor,
+ evalInput,
+ };
+ if (selectedNodeActorID) {
+ const actor = consoleActor.conn.getActor(selectedNodeActorID);
+ if (actor) {
+ owner.selectedNode = actor.rawNode;
+ }
+ }
+ return owner;
+ },
+
+ _getCommandsForCurrentEnvironment() {
+ // Not supporting extra commands in workers yet. This should be possible to
+ // add one by one as long as they don't require jsm/mjs, Cu, etc.
+ return isWorker ? new Map() : this.getAllCommands();
+ },
+
+ /**
+ * Create an object with the API we expose to the Web Console during
+ * JavaScript evaluation.
+ * This object inherits properties and methods from the Web Console actor.
+ *
+ * @param object consoleActor
+ * The related web console actor evaluating some code.
+ * @param object debuggerGlobal
+ * A Debugger.Object that wraps a content global. This is used for the
+ * Web Console Commands.
+ * @param object frame (optional)
+ * The frame where the string was evaluated.
+ * @param string evalInput
+ * String to evaluate.
+ * @param string selectedNodeActorID
+ * The Node actor ID of the currently selected DOM Element, if any is selected.
+ * @param bool preferConsoleCommandsOverLocalSymbols
+ * If true, define all bindings even if there's conflicting existing
+ * symbols. This is for the case evaluating non-user code in frame
+ * environment.
+ *
+ * @return object
+ * Object with two properties:
+ * - 'bindings', the object with all commands set as attribute on this object.
+ * - 'getHelperResult', a live getter returning the additional data the last command
+ * which executed want to convey to the frontend.
+ * (The return value of commands isn't returned to the client but it only
+ * returned to the code ran from console evaluation)
+ */
+ getWebConsoleCommands(
+ consoleActor,
+ debuggerGlobal,
+ frame,
+ evalInput,
+ selectedNodeActorID,
+ preferConsoleCommandsOverLocalSymbols
+ ) {
+ const bindings = Object.create(null);
+
+ const owner = this._createOwnerObject(
+ consoleActor,
+ debuggerGlobal,
+ evalInput,
+ selectedNodeActorID
+ );
+
+ const evalGlobal = consoleActor.evalGlobal;
+ function maybeExport(obj, name) {
+ if (typeof obj[name] != "function") {
+ return;
+ }
+
+ // By default, chrome-implemented functions that are exposed to content
+ // refuse to accept arguments that are cross-origin for the caller. This
+ // is generally the safe thing, but causes problems for certain console
+ // helpers like cd(), where we users sometimes want to pass a cross-origin
+ // window. To circumvent this restriction, we use exportFunction along
+ // with a special option designed for this purpose. See bug 1051224.
+ obj[name] = Cu.exportFunction(obj[name], evalGlobal, {
+ allowCrossOriginArguments: true,
+ });
+ }
+
+ const commands = this._getCommandsForCurrentEnvironment();
+
+ const colonOnlyCommandNames = this.getColonOnlyCommandNames();
+ for (const [name, command] of commands) {
+ // When we run user code in frame, we want to avoid overriding existing
+ // symbols with commands.
+ //
+ // When we run user code in global scope, all bindings are automatically
+ // shadowed, except for "help" function which is checked by getEvalInput.
+ //
+ // When preferConsoleCommandsOverLocalSymbols is true, ignore symbols in
+ // the current scope and always use commands ones.
+ if (
+ !preferConsoleCommandsOverLocalSymbols &&
+ (frame || name === "help") &&
+ this._isCommandNameAlreadyInScope(name, frame, debuggerGlobal)
+ ) {
+ continue;
+ }
+ // Also ignore commands which can only be run with the `:` prefix.
+ if (colonOnlyCommandNames.includes(name)) {
+ continue;
+ }
+
+ const descriptor = {
+ // We force the enumerability and the configurability (so the
+ // WebConsoleActor can reconfigure the property).
+ enumerable: true,
+ configurable: true,
+ };
+
+ if (typeof command === "function") {
+ // Function commands
+ descriptor.value = command.bind(undefined, owner);
+ maybeExport(descriptor, "value");
+
+ // Unfortunately evalWithBindings will access all bindings values,
+ // which would trigger a debuggee native call because bindings's property
+ // is using Cu.exportFunction.
+ // Put a magic symbol attribute on them in order to carefully accept
+ // all bindings as being side effect safe by default.
+ if (this._sideEffectFreeCommands.has(name)) {
+ descriptor.value.isSideEffectFree = this.SIDE_EFFECT_FREE;
+ }
+
+ // Make sure the helpers can be used during eval.
+ descriptor.value = debuggerGlobal.makeDebuggeeValue(descriptor.value);
+ } else if (typeof command?.get === "function") {
+ // Getter commands
+ descriptor.get = command.get.bind(undefined, owner);
+ maybeExport(descriptor, "get");
+
+ // See comment in previous block.
+ if (this._sideEffectFreeCommands.has(name)) {
+ descriptor.get.isSideEffectFree = this.SIDE_EFFECT_FREE;
+ }
+ }
+ Object.defineProperty(bindings, name, descriptor);
+ }
+
+ return {
+ // Use a method as commands will update owner.helperResult later
+ getHelperResult() {
+ return owner.helperResult;
+ },
+ bindings,
+ };
+ },
+
+ /**
+ * Create a function for given ':command'-style command.
+ *
+ * @param object consoleActor
+ * The related web console actor evaluating some code.
+ * @param object debuggerGlobal
+ * A Debugger.Object that wraps a content global. This is used for the
+ * Web Console Commands.
+ * @param string selectedNodeActorID
+ * The Node actor ID of the currently selected DOM Element, if any is selected.
+ * @param string evalInput
+ * String to evaluate.
+ *
+ * @return object
+ * Object with two properties:
+ * - 'commandFunc', a function corresponds to the 'commandName'
+ * - 'getHelperResult', a live getter returning the data the command
+ * which executed want to convey to the frontend.
+ */
+ executeCommand(consoleActor, debuggerGlobal, selectedNodeActorID, evalInput) {
+ const { command, args } = getCommandAndArgs(evalInput);
+ const commands = this._getCommandsForCurrentEnvironment();
+ if (!commands.has(command)) {
+ throw new Error(`Unsupported command '${command}'`);
+ }
+
+ if (args.help || args.usage) {
+ const l10nKey = USAGE_STRING_MAPPING[command];
+ if (l10nKey) {
+ const message = l10n.formatValueSync(l10nKey);
+ if (message && message !== l10nKey) {
+ return {
+ result: null,
+ helperResult: {
+ type: "usage",
+ message,
+ },
+ };
+ }
+ }
+ }
+
+ const validArguments = this._validArguments.get(command);
+ if (validArguments) {
+ for (const key of Object.keys(args)) {
+ if (!validArguments.includes(key)) {
+ throw new Error(
+ `:${command} command doesn't support '${key}' argument.`
+ );
+ }
+ }
+ }
+
+ const owner = this._createOwnerObject(
+ consoleActor,
+ debuggerGlobal,
+ evalInput,
+ selectedNodeActorID
+ );
+
+ const commandFunction = commands.get(command);
+
+ // This is where we run the command passed to register method
+ const result = commandFunction(owner, args);
+
+ return {
+ result,
+
+ // commandFunction may mutate owner.helperResult which is used
+ // to convey additional data to the frontend.
+ helperResult: owner.helperResult,
+ };
+ },
+};
+
+exports.WebConsoleCommandsManager = WebConsoleCommandsManager;
+
+/*
+ * Built-in commands.
+ *
+ * A list of helper functions used by Firebug can be found here:
+ * http://getfirebug.com/wiki/index.php/Command_Line_API
+ */
+
+/**
+ * Find the first node matching a CSS selector.
+ *
+ * @param string selector
+ * A string that is passed to window.document.querySelector
+ * @param [optional] Node element
+ * An optional Node to replace window.document
+ * @return Node or null
+ * The result of calling document.querySelector(selector).
+ */
+WebConsoleCommandsManager.register({
+ name: "$",
+ isSideEffectFree: true,
+ command(owner, selector, element) {
+ try {
+ if (
+ element &&
+ element.querySelector &&
+ (element.nodeType == Node.ELEMENT_NODE ||
+ element.nodeType == Node.DOCUMENT_NODE ||
+ element.nodeType == Node.DOCUMENT_FRAGMENT_NODE)
+ ) {
+ return element.querySelector(selector);
+ }
+ return owner.window.document.querySelector(selector);
+ } catch (err) {
+ // Throw an error like `err` but that belongs to `owner.window`.
+ throw new owner.window.DOMException(err.message, err.name);
+ }
+ },
+});
+
+/**
+ * Find the nodes matching a CSS selector.
+ *
+ * @param string selector
+ * A string that is passed to window.document.querySelectorAll.
+ * @param [optional] Node element
+ * An optional Node to replace window.document
+ * @return array of Node
+ * The result of calling document.querySelector(selector) in an array.
+ */
+WebConsoleCommandsManager.register({
+ name: "$$",
+ isSideEffectFree: true,
+ command(owner, selector, element) {
+ let scope = owner.window.document;
+ try {
+ if (
+ element &&
+ element.querySelectorAll &&
+ (element.nodeType == Node.ELEMENT_NODE ||
+ element.nodeType == Node.DOCUMENT_NODE ||
+ element.nodeType == Node.DOCUMENT_FRAGMENT_NODE)
+ ) {
+ scope = element;
+ }
+ const nodes = scope.querySelectorAll(selector);
+ const result = new owner.window.Array();
+ // Calling owner.window.Array.from() doesn't work without accessing the
+ // wrappedJSObject, so just loop through the results instead.
+ for (let i = 0; i < nodes.length; i++) {
+ result.push(nodes[i]);
+ }
+ return result;
+ } catch (err) {
+ // Throw an error like `err` but that belongs to `owner.window`.
+ throw new owner.window.DOMException(err.message, err.name);
+ }
+ },
+});
+
+/**
+ * Returns the result of the last console input evaluation
+ *
+ * @return object|undefined
+ * Returns last console evaluation or undefined
+ */
+WebConsoleCommandsManager.register({
+ name: "$_",
+ isSideEffectFree: true,
+ command: {
+ get(owner) {
+ return owner.consoleActor.getLastConsoleInputEvaluation();
+ },
+ },
+});
+
+/**
+ * Runs an xPath query and returns all matched nodes.
+ *
+ * @param string xPath
+ * xPath search query to execute.
+ * @param [optional] Node context
+ * Context to run the xPath query on. Uses window.document if not set.
+ * @param [optional] string|number resultType
+ Specify the result type. Default value XPathResult.ANY_TYPE
+ * @return array of Node
+ */
+WebConsoleCommandsManager.register({
+ name: "$x",
+ isSideEffectFree: true,
+ command(
+ owner,
+ xPath,
+ context,
+ resultType = owner.window.XPathResult.ANY_TYPE
+ ) {
+ const nodes = new owner.window.Array();
+ // Not waiving Xrays, since we want the original Document.evaluate function,
+ // instead of anything that's been redefined.
+ const doc = owner.window.document;
+ context = context || doc;
+ switch (resultType) {
+ case "number":
+ resultType = owner.window.XPathResult.NUMBER_TYPE;
+ break;
+
+ case "string":
+ resultType = owner.window.XPathResult.STRING_TYPE;
+ break;
+
+ case "bool":
+ resultType = owner.window.XPathResult.BOOLEAN_TYPE;
+ break;
+
+ case "node":
+ resultType = owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE;
+ break;
+
+ case "nodes":
+ resultType = owner.window.XPathResult.UNORDERED_NODE_ITERATOR_TYPE;
+ break;
+ }
+ const results = doc.evaluate(xPath, context, null, resultType, null);
+ if (results.resultType === owner.window.XPathResult.NUMBER_TYPE) {
+ return results.numberValue;
+ }
+ if (results.resultType === owner.window.XPathResult.STRING_TYPE) {
+ return results.stringValue;
+ }
+ if (results.resultType === owner.window.XPathResult.BOOLEAN_TYPE) {
+ return results.booleanValue;
+ }
+ if (
+ results.resultType === owner.window.XPathResult.ANY_UNORDERED_NODE_TYPE ||
+ results.resultType === owner.window.XPathResult.FIRST_ORDERED_NODE_TYPE
+ ) {
+ return results.singleNodeValue;
+ }
+ if (
+ results.resultType ===
+ owner.window.XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE ||
+ results.resultType === owner.window.XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
+ ) {
+ for (let i = 0; i < results.snapshotLength; i++) {
+ nodes.push(results.snapshotItem(i));
+ }
+ return nodes;
+ }
+
+ let node;
+ while ((node = results.iterateNext())) {
+ nodes.push(node);
+ }
+
+ return nodes;
+ },
+});
+
+/**
+ * Returns the currently selected object in the highlighter.
+ *
+ * @return Object representing the current selection in the
+ * Inspector, or null if no selection exists.
+ */
+WebConsoleCommandsManager.register({
+ name: "$0",
+ isSideEffectFree: true,
+ command: {
+ get(owner) {
+ return owner.makeDebuggeeValue(owner.selectedNode);
+ },
+ },
+});
+
+/**
+ * Clears the output of the WebConsole.
+ */
+WebConsoleCommandsManager.register({
+ name: "clear",
+ isSideEffectFree: false,
+ command(owner) {
+ owner.helperResult = {
+ type: "clearOutput",
+ };
+ },
+});
+
+/**
+ * Clears the input history of the WebConsole.
+ */
+WebConsoleCommandsManager.register({
+ name: "clearHistory",
+ isSideEffectFree: false,
+ command(owner) {
+ owner.helperResult = {
+ type: "clearHistory",
+ };
+ },
+});
+
+/**
+ * Returns the result of Object.keys(object).
+ *
+ * @param object object
+ * Object to return the property names from.
+ * @return array of strings
+ */
+WebConsoleCommandsManager.register({
+ name: "keys",
+ isSideEffectFree: true,
+ command(owner, object) {
+ // Need to waive Xrays so we can iterate functions and accessor properties
+ return Cu.cloneInto(Object.keys(Cu.waiveXrays(object)), owner.window);
+ },
+});
+
+/**
+ * Returns the values of all properties on object.
+ *
+ * @param object object
+ * Object to display the values from.
+ * @return array of string
+ */
+WebConsoleCommandsManager.register({
+ name: "values",
+ isSideEffectFree: true,
+ command(owner, object) {
+ const values = [];
+ // Need to waive Xrays so we can iterate functions and accessor properties
+ const waived = Cu.waiveXrays(object);
+ const names = Object.getOwnPropertyNames(waived);
+
+ for (const name of names) {
+ values.push(waived[name]);
+ }
+
+ return Cu.cloneInto(values, owner.window);
+ },
+});
+
+/**
+ * Opens a help window in MDN.
+ */
+WebConsoleCommandsManager.register({
+ name: "help",
+ isSideEffectFree: false,
+ command(owner, args) {
+ owner.helperResult = { type: "help" };
+ },
+});
+
+/**
+ * Inspects the passed object. This is done by opening the PropertyPanel.
+ *
+ * @param object object
+ * Object to inspect.
+ */
+WebConsoleCommandsManager.register({
+ name: "inspect",
+ isSideEffectFree: false,
+ command(owner, object, forceExpandInConsole = false) {
+ const dbgObj = owner.preprocessDebuggerObject(
+ owner.makeDebuggeeValue(object)
+ );
+
+ const grip = owner.createValueGrip(dbgObj);
+ owner.helperResult = {
+ type: "inspectObject",
+ input: owner.evalInput,
+ object: grip,
+ forceExpandInConsole,
+ };
+ },
+});
+
+/**
+ * Copy the String representation of a value to the clipboard.
+ *
+ * @param any value
+ * A value you want to copy as a string.
+ * @return void
+ */
+WebConsoleCommandsManager.register({
+ name: "copy",
+ isSideEffectFree: false,
+ command(owner, value) {
+ let payload;
+ try {
+ if (Element.isInstance(value)) {
+ payload = value.outerHTML;
+ } else if (typeof value == "string") {
+ payload = value;
+ } else {
+ payload = JSON.stringify(value, null, " ");
+ }
+ } catch (ex) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.error.commands.copyError",
+ messageArgs: [ex.toString()],
+ };
+ return;
+ }
+ owner.helperResult = {
+ type: "copyValueToClipboard",
+ value: payload,
+ };
+ },
+});
+
+/**
+ * Take a screenshot of a page.
+ *
+ * @param object args
+ * The arguments to be passed to the screenshot
+ * @return void
+ */
+WebConsoleCommandsManager.register({
+ name: "screenshot",
+ isSideEffectFree: false,
+ command(owner, args = {}) {
+ owner.helperResult = {
+ type: "screenshotOutput",
+ args,
+ };
+ },
+});
+
+/**
+ * Shows a history of commands and expressions previously executed within the command line.
+ *
+ * @param object args
+ * The arguments to be passed to the history
+ * @return void
+ */
+WebConsoleCommandsManager.register({
+ name: "history",
+ isSideEffectFree: false,
+ command(owner, args = {}) {
+ owner.helperResult = {
+ type: "historyOutput",
+ args,
+ };
+ },
+});
+
+/**
+ * Block specific resource from loading
+ *
+ * @param object args
+ * an object with key "url", i.e. a filter
+ *
+ * @return void
+ */
+WebConsoleCommandsManager.register({
+ name: "block",
+ isSideEffectFree: false,
+ command(owner, args = {}) {
+ // Note that this command is implemented in the frontend, from actions's input.js
+ // We only forward the command arguments back to the client.
+ if (!args.url) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.messages.commands.blockArgMissing",
+ };
+ return;
+ }
+
+ owner.helperResult = {
+ type: "blockURL",
+ args,
+ };
+ },
+ validArguments: ["url"],
+});
+
+/*
+ * Unblock a blocked a resource
+ *
+ * @param object filter
+ * an object with key "url", i.e. a filter
+ *
+ * @return void
+ */
+WebConsoleCommandsManager.register({
+ name: "unblock",
+ isSideEffectFree: false,
+ command(owner, args = {}) {
+ // Note that this command is implemented in the frontend, from actions's input.js
+ // We only forward the command arguments back to the client.
+ if (!args.url) {
+ owner.helperResult = {
+ type: "error",
+ message: "webconsole.messages.commands.blockArgMissing",
+ };
+ return;
+ }
+
+ owner.helperResult = {
+ type: "unblockURL",
+ args,
+ };
+ },
+ validArguments: ["url"],
+});
+
+/*
+ * Toggle JavaScript tracing
+ *
+ * @param object args
+ * An object with various configuration only valid when starting the tracing.
+ *
+ * @return void
+ */
+WebConsoleCommandsManager.register({
+ name: "trace",
+ isSideEffectFree: false,
+ command(owner, args) {
+ if (isWorker) {
+ throw new Error(":trace command isn't supported in workers");
+ }
+ // Disable :trace command on worker until this feature is enabled by default
+ if (
+ !Services.prefs.getBoolPref(
+ "devtools.debugger.features.javascript-tracing",
+ false
+ )
+ ) {
+ throw new Error(
+ ":trace requires 'devtools.debugger.features.javascript-tracing' preference to be true"
+ );
+ }
+ const tracerActor =
+ owner.consoleActor.parentActor.getTargetScopedActor("tracer");
+ const logMethod = args.logMethod || "console";
+ // Note that toggleTracing does some sanity checks and will throw meaningful error
+ // when the arguments are wrong.
+ const enabled = tracerActor.toggleTracing({
+ logMethod,
+ prefix: args.prefix || null,
+ traceValues: !!args.values,
+ traceOnNextInteraction: args["on-next-interaction"] || null,
+ maxDepth: args["max-depth"] || null,
+ maxRecords: args["max-records"] || null,
+ });
+
+ owner.helperResult = {
+ type: "traceOutput",
+ enabled,
+ logMethod,
+ };
+ },
+ validArguments: [
+ "logMethod",
+ "max-depth",
+ "max-records",
+ "on-next-interaction",
+ "prefix",
+ "values",
+ ],
+});
diff --git a/devtools/server/actors/webconsole/commands/moz.build b/devtools/server/actors/webconsole/commands/moz.build
new file mode 100644
index 0000000000..9e0516b172
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands/moz.build
@@ -0,0 +1,10 @@
+# -*- 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(
+ "manager.js",
+ "parser.js",
+)
diff --git a/devtools/server/actors/webconsole/commands/parser.js b/devtools/server/actors/webconsole/commands/parser.js
new file mode 100644
index 0000000000..49a7a5a1d7
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands/parser.js
@@ -0,0 +1,249 @@
+/* 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,
+ ["WebConsoleCommandsManager"],
+ "resource://devtools/server/actors/webconsole/commands/manager.js",
+ true
+);
+
+const COMMAND = "command";
+const KEY = "key";
+const ARG = "arg";
+
+const COMMAND_PREFIX = /^:/;
+const KEY_PREFIX = /^--/;
+
+// default value for flags
+const DEFAULT_VALUE = true;
+const COMMAND_DEFAULT_FLAG = {
+ block: "url",
+ screenshot: "filename",
+ unblock: "url",
+};
+
+/**
+ * When given a string that begins with `:` and a unix style string,
+ * returns the command name and the arguments.
+ * Throws if the command doesn't exist.
+ * This is intended to be used by the WebConsole actor only.
+ *
+ * @param String string
+ * A string to format that begins with `:`.
+ *
+ * @returns Object The command name and the arguments
+ * { command: String, args: Object }
+ */
+function getCommandAndArgs(string) {
+ if (!isCommand(string)) {
+ throw Error("getCommandAndArgs was called without `:`");
+ }
+ string = string.trim();
+ if (string === ":") {
+ throw Error("Missing a command name after ':'");
+ }
+ const tokens = string.split(/\s+/).map(createToken);
+ return parseCommand(tokens);
+}
+
+/**
+ * creates a token object depending on a string which as a prefix,
+ * either `:` for a command or `--` for a key, or nothing for an argument
+ *
+ * @param String string
+ * A string to use as the basis for the token
+ *
+ * @returns Object Token Object, with the following shape
+ * { type: String, value: String }
+ */
+function createToken(string) {
+ if (isCommand(string)) {
+ const value = string.replace(COMMAND_PREFIX, "");
+ if (!value) {
+ throw Error("Missing a command name after ':'");
+ }
+ if (!WebConsoleCommandsManager.getAllColonCommandNames().includes(value)) {
+ throw Error(`'${value}' is not a valid command`);
+ }
+ return { type: COMMAND, value };
+ }
+ if (isKey(string)) {
+ const value = string.replace(KEY_PREFIX, "");
+ if (!value) {
+ throw Error("invalid flag");
+ }
+ return { type: KEY, value };
+ }
+ return { type: ARG, value: string };
+}
+
+/**
+ * returns a command Tree object for a set of tokens
+ *
+ *
+ * @param Array Tokens tokens
+ * An array of Token objects
+ *
+ * @returns Object Tree Object, with the following shape
+ * { command: String, args: Object }
+ */
+function parseCommand(tokens) {
+ let command = null;
+ const args = {};
+
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i];
+ if (token.type === COMMAND) {
+ if (command) {
+ // we are throwing here because two commands have been passed and it is unclear
+ // what the user's intention was
+ throw Error(
+ "Executing multiple commands in one evaluation is not supported"
+ );
+ }
+ command = token.value;
+ }
+
+ if (token.type === KEY) {
+ const nextTokenIndex = i + 1;
+ const nextToken = tokens[nextTokenIndex];
+ let values = args[token.value] || DEFAULT_VALUE;
+ if (nextToken && nextToken.type === ARG) {
+ const { value, offset } = collectString(
+ nextToken,
+ tokens,
+ nextTokenIndex
+ );
+ // in order for JSON.stringify to correctly output values, they must be correctly
+ // typed
+ // As per the old GCLI documentation, we can only have one value associated with a
+ // flag but multiple flags with the same name can exist and should be combined
+ // into and array. Here we are associating only the value on the right hand
+ // side if it is of type `arg` as a single value; the second case initializes
+ // an array, and the final case pushes a value to an existing array
+ const typedValue = getTypedValue(value);
+ if (values === DEFAULT_VALUE) {
+ values = typedValue;
+ } else if (!Array.isArray(values)) {
+ values = [values, typedValue];
+ } else {
+ values.push(typedValue);
+ }
+ // skip the next token since we have already consumed it
+ i = nextTokenIndex + offset;
+ }
+ args[token.value] = values;
+ }
+
+ // Since this has only been implemented for screenshot, we can only have one default
+ // value. Eventually we may have more default values. For now, ignore multiple
+ // unflagged args
+ const defaultFlag = COMMAND_DEFAULT_FLAG[command];
+ if (token.type === ARG && !args[defaultFlag]) {
+ const { value, offset } = collectString(token, tokens, i);
+ // Throw if the command isn't registered in COMMAND_DEFAULT_FLAG
+ // as this command may not expect any argument without an explicit argument name like "-name arg"
+ if (!defaultFlag) {
+ throw new Error(
+ `:${command} command doesn't support unnamed '${value}' argument.`
+ );
+ }
+ args[defaultFlag] = getTypedValue(value);
+ i = i + offset;
+ }
+ }
+ return { command, args };
+}
+
+const stringChars = ['"', "'", "`"];
+function isStringChar(testChar) {
+ return stringChars.includes(testChar);
+}
+
+function checkLastChar(string, testChar) {
+ const lastChar = string[string.length - 1];
+ return lastChar === testChar;
+}
+
+function hasUnescapedChar(value, char, rightOffset, leftOffset) {
+ const lastPos = value.length - 1;
+ const string = value.slice(rightOffset, lastPos - leftOffset);
+ const index = string.indexOf(char);
+ if (index === -1) {
+ return false;
+ }
+ const prevChar = index > 0 ? string[index - 1] : null;
+ // return false if the unexpected character is escaped, true if it is not
+ return prevChar !== "\\";
+}
+
+function collectString(token, tokens, index) {
+ const firstChar = token.value[0];
+ const isString = isStringChar(firstChar);
+ const UNESCAPED_CHAR_ERROR = segment =>
+ `String has unescaped \`${firstChar}\` in [${segment}...],` +
+ " may miss a space between arguments";
+ let value = token.value;
+
+ // the test value is not a string, or it is a string but a complete one
+ // i.e. `"test"`, as opposed to `"foo`. In either case, this we can return early
+ if (!isString || checkLastChar(value, firstChar)) {
+ return { value, offset: 0 };
+ }
+
+ if (hasUnescapedChar(value, firstChar, 1, 0)) {
+ throw Error(UNESCAPED_CHAR_ERROR(value));
+ }
+
+ let offset = null;
+ for (let i = index + 1; i <= tokens.length; i++) {
+ if (i === tokens.length) {
+ throw Error("String does not terminate");
+ }
+
+ const nextToken = tokens[i];
+ if (nextToken.type !== ARG) {
+ throw Error(`String does not terminate before flag "${nextToken.value}"`);
+ }
+
+ value = `${value} ${nextToken.value}`;
+
+ if (hasUnescapedChar(nextToken.value, firstChar, 0, 1)) {
+ throw Error(UNESCAPED_CHAR_ERROR(value));
+ }
+
+ if (checkLastChar(nextToken.value, firstChar)) {
+ offset = i - index;
+ break;
+ }
+ }
+ return { value, offset };
+}
+
+function isCommand(string) {
+ return COMMAND_PREFIX.test(string);
+}
+
+function isKey(string) {
+ return KEY_PREFIX.test(string);
+}
+
+function getTypedValue(value) {
+ if (!isNaN(value)) {
+ return Number(value);
+ }
+ if (value === "true" || value === "false") {
+ return Boolean(value);
+ }
+ if (isStringChar(value[0])) {
+ return value.slice(1, value.length - 1);
+ }
+ return value;
+}
+
+exports.getCommandAndArgs = getCommandAndArgs;
+exports.isCommand = isCommand;
diff --git a/devtools/server/actors/webconsole/eager-ecma-allowlist.js b/devtools/server/actors/webconsole/eager-ecma-allowlist.js
new file mode 100644
index 0000000000..defe98ad8b
--- /dev/null
+++ b/devtools/server/actors/webconsole/eager-ecma-allowlist.js
@@ -0,0 +1,249 @@
+/* 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/. */
+/* global BigInt */
+
+"use strict";
+
+function matchingProperties(obj, regexp) {
+ return Object.getOwnPropertyNames(obj)
+ .filter(n => regexp.test(n))
+ .map(n => obj[n])
+ .filter(v => typeof v == "function");
+}
+
+function allProperties(obj) {
+ return matchingProperties(obj, /./);
+}
+
+function getter(obj, name) {
+ return Object.getOwnPropertyDescriptor(obj, name).get;
+}
+
+const TypedArray = Reflect.getPrototypeOf(Int8Array);
+
+const functionAllowList = [
+ Array,
+ Array.from,
+ Array.isArray,
+ Array.of,
+ Array.prototype.concat,
+ Array.prototype.entries,
+ Array.prototype.every,
+ Array.prototype.filter,
+ Array.prototype.find,
+ Array.prototype.findIndex,
+ Array.prototype.flat,
+ Array.prototype.flatMap,
+ Array.prototype.forEach,
+ Array.prototype.includes,
+ Array.prototype.indexOf,
+ Array.prototype.join,
+ Array.prototype.keys,
+ Array.prototype.lastIndexOf,
+ Array.prototype.map,
+ Array.prototype.reduce,
+ Array.prototype.reduceRight,
+ Array.prototype.slice,
+ Array.prototype.some,
+ Array.prototype.values,
+ ArrayBuffer,
+ ArrayBuffer.isView,
+ ArrayBuffer.prototype.slice,
+ BigInt,
+ ...allProperties(BigInt),
+ Boolean,
+ DataView,
+ Date,
+ Date.now,
+ Date.parse,
+ Date.UTC,
+ ...matchingProperties(Date.prototype, /^get/),
+ ...matchingProperties(Date.prototype, /^to.*?String$/),
+ Error,
+ Function,
+ Function.prototype.apply,
+ Function.prototype.bind,
+ Function.prototype.call,
+ Function.prototype[Symbol.hasInstance],
+ Int8Array,
+ Uint8Array,
+ Uint8ClampedArray,
+ Int16Array,
+ Uint16Array,
+ Int32Array,
+ Uint32Array,
+ Float32Array,
+ Float64Array,
+ TypedArray.from,
+ TypedArray.of,
+ TypedArray.prototype.entries,
+ TypedArray.prototype.every,
+ TypedArray.prototype.filter,
+ TypedArray.prototype.find,
+ TypedArray.prototype.findIndex,
+ TypedArray.prototype.forEach,
+ TypedArray.prototype.includes,
+ TypedArray.prototype.indexOf,
+ TypedArray.prototype.join,
+ TypedArray.prototype.keys,
+ TypedArray.prototype.lastIndexOf,
+ TypedArray.prototype.map,
+ TypedArray.prototype.reduce,
+ TypedArray.prototype.reduceRight,
+ TypedArray.prototype.slice,
+ TypedArray.prototype.some,
+ TypedArray.prototype.subarray,
+ TypedArray.prototype.values,
+ ...allProperties(JSON),
+ Map,
+ Map.prototype.forEach,
+ Map.prototype.get,
+ Map.prototype.has,
+ Map.prototype.entries,
+ Map.prototype.keys,
+ Map.prototype.values,
+ ...allProperties(Math),
+ Number,
+ ...allProperties(Number),
+ ...allProperties(Number.prototype),
+ Object,
+ Object.create,
+ Object.keys,
+ Object.entries,
+ Object.getOwnPropertyDescriptor,
+ Object.getOwnPropertyDescriptors,
+ Object.getOwnPropertyNames,
+ Object.getOwnPropertySymbols,
+ Object.getPrototypeOf,
+ Object.is,
+ Object.isExtensible,
+ Object.isFrozen,
+ Object.isSealed,
+ Object.values,
+ Object.prototype.hasOwnProperty,
+ Object.prototype.isPrototypeOf,
+ Proxy,
+ Proxy.revocable,
+ Reflect.apply,
+ Reflect.construct,
+ Reflect.get,
+ Reflect.getOwnPropertyDescriptor,
+ Reflect.getPrototypeOf,
+ Reflect.has,
+ Reflect.isExtensible,
+ Reflect.ownKeys,
+ RegExp,
+ RegExp.prototype.exec,
+ RegExp.prototype.test,
+ RegExp.prototype[Symbol.match],
+ RegExp.prototype[Symbol.search],
+ RegExp.prototype[Symbol.replace],
+ Set,
+ Set.prototype.entries,
+ Set.prototype.forEach,
+ Set.prototype.has,
+ Set.prototype.values,
+ String,
+ ...allProperties(String),
+ ...allProperties(String.prototype),
+ Symbol,
+ Symbol.keyFor,
+ WeakMap,
+ WeakMap.prototype.get,
+ WeakMap.prototype.has,
+ WeakSet,
+ WeakSet.prototype.has,
+ decodeURI,
+ decodeURIComponent,
+ encodeURI,
+ encodeURIComponent,
+ escape,
+ isFinite,
+ isNaN,
+ unescape,
+];
+
+const getterAllowList = [
+ getter(ArrayBuffer.prototype, "byteLength"),
+ getter(ArrayBuffer, Symbol.species),
+ getter(Array, Symbol.species),
+ getter(DataView.prototype, "buffer"),
+ getter(DataView.prototype, "byteLength"),
+ getter(DataView.prototype, "byteOffset"),
+ getter(Error.prototype, "stack"),
+ getter(Function.prototype, "arguments"),
+ getter(Function.prototype, "caller"),
+ getter(Intl.Locale.prototype, "baseName"),
+ getter(Intl.Locale.prototype, "calendar"),
+ getter(Intl.Locale.prototype, "caseFirst"),
+ getter(Intl.Locale.prototype, "collation"),
+ getter(Intl.Locale.prototype, "hourCycle"),
+ getter(Intl.Locale.prototype, "numeric"),
+ getter(Intl.Locale.prototype, "numberingSystem"),
+ getter(Intl.Locale.prototype, "language"),
+ getter(Intl.Locale.prototype, "script"),
+ getter(Intl.Locale.prototype, "region"),
+ getter(Map.prototype, "size"),
+ getter(Map, Symbol.species),
+ // NOTE: Object.prototype.__proto__ is not safe, because it can internally
+ // invoke Proxy getPrototypeOf handler.
+ getter(Promise, Symbol.species),
+ getter(RegExp, "input"),
+ getter(RegExp, "lastMatch"),
+ getter(RegExp, "lastParen"),
+ getter(RegExp, "leftContext"),
+ getter(RegExp, "rightContext"),
+ getter(RegExp, "$1"),
+ getter(RegExp, "$2"),
+ getter(RegExp, "$3"),
+ getter(RegExp, "$4"),
+ getter(RegExp, "$5"),
+ getter(RegExp, "$6"),
+ getter(RegExp, "$7"),
+ getter(RegExp, "$8"),
+ getter(RegExp, "$9"),
+ getter(RegExp, "$_"),
+ getter(RegExp, "$&"),
+ getter(RegExp, "$+"),
+ getter(RegExp, "$`"),
+ getter(RegExp, "$'"),
+ getter(RegExp.prototype, "dotAll"),
+ getter(RegExp.prototype, "flags"),
+ getter(RegExp.prototype, "global"),
+ getter(RegExp.prototype, "hasIndices"),
+ getter(RegExp.prototype, "ignoreCase"),
+ getter(RegExp.prototype, "multiline"),
+ getter(RegExp.prototype, "source"),
+ getter(RegExp.prototype, "sticky"),
+ getter(RegExp.prototype, "unicode"),
+ getter(RegExp.prototype, "unicodeSets"),
+ getter(RegExp, Symbol.species),
+ getter(Set.prototype, "size"),
+ getter(Set, Symbol.species),
+ getter(Symbol.prototype, "description"),
+ getter(TypedArray.prototype, "buffer"),
+ getter(TypedArray.prototype, "byteLength"),
+ getter(TypedArray.prototype, "byteOffset"),
+ getter(TypedArray.prototype, "length"),
+ getter(TypedArray.prototype, Symbol.toStringTag),
+ getter(TypedArray, Symbol.species),
+];
+
+// TODO: Integrate in main list when changes array by copy ships by default
+const changesArrayByCopy = [
+ Array.prototype.toReversed,
+ Array.prototype.toSorted,
+ Array.prototype.toSpliced,
+ Array.prototype.with,
+ TypedArray.prototype.toReversed,
+ TypedArray.prototype.toSorted,
+ TypedArray.prototype.with,
+];
+for (const fn of changesArrayByCopy) {
+ if (typeof fn == "function") {
+ functionAllowList.push(fn);
+ }
+}
+
+module.exports = { functions: functionAllowList, getters: getterAllowList };
diff --git a/devtools/server/actors/webconsole/eager-function-allowlist.js b/devtools/server/actors/webconsole/eager-function-allowlist.js
new file mode 100644
index 0000000000..363591523d
--- /dev/null
+++ b/devtools/server/actors/webconsole/eager-function-allowlist.js
@@ -0,0 +1,52 @@
+/* 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 idlPureAllowlist = require("resource://devtools/server/actors/webconsole/webidl-pure-allowlist.js");
+
+const natives = [];
+if (Components.Constructor && Cu) {
+ const sandbox = Cu.Sandbox(
+ Components.Constructor("@mozilla.org/systemprincipal;1", "nsIPrincipal")(),
+ {
+ invisibleToDebugger: true,
+ wantGlobalProperties: Object.keys(idlPureAllowlist),
+ }
+ );
+
+ function maybePush(maybeFunc) {
+ if (maybeFunc) {
+ natives.push(maybeFunc);
+ }
+ }
+
+ function collectMethods(obj, methods) {
+ for (const name of methods) {
+ maybePush(obj[name]);
+ }
+ }
+
+ for (const [iface, ifaceData] of Object.entries(idlPureAllowlist)) {
+ const ctor = sandbox[iface];
+ if (!ctor) {
+ continue;
+ }
+
+ if ("static" in ifaceData) {
+ collectMethods(ctor, ifaceData.static);
+ }
+
+ if ("prototype" in ifaceData) {
+ const proto = ctor.prototype;
+ if (!proto) {
+ continue;
+ }
+
+ collectMethods(proto, ifaceData.prototype);
+ }
+ }
+}
+
+module.exports = { natives };
diff --git a/devtools/server/actors/webconsole/eval-with-debugger.js b/devtools/server/actors/webconsole/eval-with-debugger.js
new file mode 100644
index 0000000000..d422d6cd5e
--- /dev/null
+++ b/devtools/server/actors/webconsole/eval-with-debugger.js
@@ -0,0 +1,710 @@
+/* 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 Debugger = require("Debugger");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ Reflect: "resource://gre/modules/reflect.sys.mjs",
+});
+loader.lazyRequireGetter(
+ this,
+ ["isCommand"],
+ "resource://devtools/server/actors/webconsole/commands/parser.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WebConsoleCommandsManager",
+ "resource://devtools/server/actors/webconsole/commands/manager.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "LongStringActor",
+ "resource://devtools/server/actors/string.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "eagerEcmaAllowlist",
+ "resource://devtools/server/actors/webconsole/eager-ecma-allowlist.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "eagerFunctionAllowlist",
+ "resource://devtools/server/actors/webconsole/eager-function-allowlist.js"
+);
+
+function isObject(value) {
+ return Object(value) === value;
+}
+
+/**
+ * Evaluates a string using the debugger API.
+ *
+ * To allow the variables view to update properties from the Web Console we
+ * provide the "selectedObjectActor" mechanism: the Web Console tells the
+ * ObjectActor ID for which it desires to evaluate an expression. The
+ * Debugger.Object pointed at by the actor ID is bound such that it is
+ * available during expression evaluation (executeInGlobalWithBindings()).
+ *
+ * Example:
+ * _self['foobar'] = 'test'
+ * where |_self| refers to the desired object.
+ *
+ * The |frameActor| property allows the Web Console client to provide the
+ * frame actor ID, such that the expression can be evaluated in the
+ * user-selected stack frame.
+ *
+ * For the above to work we need the debugger and the Web Console to share
+ * a connection, otherwise the Web Console actor will not find the frame
+ * actor.
+ *
+ * The Debugger.Frame comes from the jsdebugger's Debugger instance, which
+ * is different from the Web Console's Debugger instance. This means that
+ * for evaluation to work, we need to create a new instance for the Web
+ * Console Commands helpers - they need to be Debugger.Objects coming from the
+ * jsdebugger's Debugger instance.
+ *
+ * When |selectedObjectActor| is used objects can come from different iframes,
+ * from different domains. To avoid permission-related errors when objects
+ * come from a different window, we also determine the object's own global,
+ * such that evaluation happens in the context of that global. This means that
+ * evaluation will happen in the object's iframe, rather than the top level
+ * window.
+ *
+ * @param string string
+ * String to evaluate.
+ * @param object [options]
+ * Options for evaluation:
+ * - selectedObjectActor: the ObjectActor ID to use for evaluation.
+ * |evalWithBindings()| will be called with one additional binding:
+ * |_self| which will point to the Debugger.Object of the given
+ * ObjectActor. Executes with the top level window as the global.
+ * - frameActor: the FrameActor ID to use for evaluation. The given
+ * debugger frame is used for evaluation, instead of the global window.
+ * - selectedNodeActor: the NodeActor ID of the currently selected node
+ * in the Inspector (or null, if there is no selection). This is used
+ * for helper functions that make reference to the currently selected
+ * node, like $0.
+ * - innerWindowID: An optional window id to use instead of webConsole.evalWindow.
+ * This is used by function that need to evaluate in a different window for which
+ * we don't have a dedicated target (for example a non-remote iframe).
+ * - eager: Set to true if you want the evaluation to bail if it may have side effects.
+ * - url: the url to evaluate the script as. Defaults to "debugger eval code",
+ * or "debugger eager eval code" if eager is true.
+ * - preferConsoleCommandsOverLocalSymbols: Set to true if console commands
+ * should override local symbols.
+ * @param object webConsole
+ *
+ * @return object
+ * An object that holds the following properties:
+ * - dbg: the debugger where the string was evaluated.
+ * - frame: (optional) the frame where the string was evaluated.
+ * - global: the Debugger.Object for the global where the string was evaluated in.
+ * - result: the result of the evaluation.
+ */
+function evalWithDebugger(string, options = {}, webConsole) {
+ const trimmedString = string.trim();
+ // The help function needs to be easy to guess, so accept "?" as a shortcut
+ if (trimmedString === "?") {
+ return evalWithDebugger(":help", options, webConsole);
+ }
+
+ const isCmd = isCommand(trimmedString);
+
+ if (isCmd && options.eager) {
+ return {
+ result: null,
+ };
+ }
+
+ const { frame, dbg } = getFrameDbg(options, webConsole);
+
+ const { dbgGlobal, bindSelf } = getDbgGlobal(options, dbg, webConsole);
+
+ // If the strings starts with a `:`, do not try to evaluate the strings
+ // and instead only call the related command function directly from
+ // the privileged codebase.
+ if (isCmd) {
+ try {
+ return WebConsoleCommandsManager.executeCommand(
+ webConsole,
+ dbgGlobal,
+ options.selectedNodeActor,
+ string
+ );
+ } catch (e) {
+ // Catch any exception and return a result similar to the output
+ // of executeCommand to notify the client about this unexpected error.
+ return {
+ helperResult: {
+ type: "exception",
+ message: e.message,
+ },
+ };
+ }
+ }
+
+ const helpers = WebConsoleCommandsManager.getWebConsoleCommands(
+ webConsole,
+ dbgGlobal,
+ frame,
+ string,
+ options.selectedNodeActor,
+ options.preferConsoleCommandsOverLocalSymbols
+ );
+ let { bindings } = helpers;
+
+ // Ease calling the help command by not requiring the "()".
+ // But wait for the bindings computation in order to know if "help" variable
+ // was overloaded by the page. If it is missing from bindings, it is overloaded and we should
+ // display its value by doing a regular evaluation.
+ if (trimmedString === "help" && bindings.help) {
+ return evalWithDebugger(":help", options, webConsole);
+ }
+
+ // '_self' refers to the JS object references via options.selectedObjectActor.
+ // This isn't exposed on typical console evaluation, but only when "Store As Global"
+ // runs an invisible script storing `_self` into `temp${i}`.
+ if (bindSelf) {
+ bindings._self = bindSelf;
+ }
+
+ // Log points calls this method from the server side and pass additional variables
+ // to be exposed to the evaluated JS string
+ if (options.bindings) {
+ bindings = { ...bindings, ...options.bindings };
+ }
+
+ const evalOptions = {};
+
+ const urlOption =
+ options.url || (options.eager ? "debugger eager eval code" : null);
+ if (typeof urlOption === "string") {
+ evalOptions.url = urlOption;
+ }
+
+ if (typeof options.lineNumber === "number") {
+ evalOptions.lineNumber = options.lineNumber;
+ }
+
+ if (options.disableBreaks || options.eager) {
+ // When we are disabling breakpoints for a given evaluation, or when we are doing an eager evaluation,
+ // also prevent spawning related Debugger.Source object to avoid showing it
+ // in the debugger UI
+ evalOptions.hideFromDebugger = true;
+ }
+
+ if (options.preferConsoleCommandsOverLocalSymbols) {
+ evalOptions.useInnerBindings = true;
+ }
+
+ updateConsoleInputEvaluation(dbg, webConsole);
+
+ const evalString = getEvalInput(string, bindings);
+ const result = getEvalResult(
+ dbg,
+ evalString,
+ evalOptions,
+ bindings,
+ frame,
+ dbgGlobal,
+ options.eager
+ );
+
+ // Attempt to initialize any declarations found in the evaluated string
+ // since they may now be stuck in an "initializing" state due to the
+ // error. Already-initialized bindings will be ignored.
+ if (!frame && result && "throw" in result) {
+ forceLexicalInitForVariableDeclarationsInThrowingExpression(
+ dbgGlobal,
+ string
+ );
+ }
+
+ return {
+ result,
+ // Retrieve the result of commands, if any ran
+ helperResult: helpers.getHelperResult(),
+ dbg,
+ frame,
+ dbgGlobal,
+ };
+}
+exports.evalWithDebugger = evalWithDebugger;
+
+/**
+ * Sub-function to reduce the complexity of evalWithDebugger.
+ * This focuses on calling Debugger.Frame or Debugger.Object eval methods.
+ *
+ * @param {Debugger} dbg
+ * @param {String} string
+ * The string to evaluate.
+ * @param {Object} evalOptions
+ * Spidermonkey options to pass to eval methods.
+ * @param {Object} bindings
+ * Dictionary object with symbols to override in the evaluation.
+ * @param {Debugger.Frame} frame
+ * If paused, the paused frame.
+ * @param {Debugger.Object} dbgGlobal
+ * The target's global.
+ * @param {Boolean} eager
+ * Is this an eager evaluation?
+ * @return {Object}
+ * The evaluation result object.
+ * See `Debugger.Ojbect.executeInGlobalWithBindings` definition.
+ */
+function getEvalResult(
+ dbg,
+ string,
+ evalOptions,
+ bindings,
+ frame,
+ dbgGlobal,
+ eager
+) {
+ // When we are doing an eager evaluation, we aren't using the target's Debugger object
+ // but a special one, dedicated to each evaluation.
+ let noSideEffectDebugger = null;
+ if (eager) {
+ noSideEffectDebugger = makeSideeffectFreeDebugger(dbg);
+
+ // When a sideeffect-free debugger has been created, we need to eval
+ // in the context of that debugger in order for the side-effect tracking
+ // to apply.
+ if (frame) {
+ frame = noSideEffectDebugger.adoptFrame(frame);
+ } else {
+ dbgGlobal = noSideEffectDebugger.adoptDebuggeeValue(dbgGlobal);
+ }
+ if (bindings) {
+ bindings = Object.keys(bindings).reduce((acc, key) => {
+ acc[key] = noSideEffectDebugger.adoptDebuggeeValue(bindings[key]);
+ return acc;
+ }, {});
+ }
+ }
+
+ try {
+ let result;
+ if (frame) {
+ result = frame.evalWithBindings(string, bindings, evalOptions);
+ } else {
+ result = dbgGlobal.executeInGlobalWithBindings(
+ string,
+ bindings,
+ evalOptions
+ );
+ }
+ if (noSideEffectDebugger && result) {
+ if ("return" in result) {
+ result.return = dbg.adoptDebuggeeValue(result.return);
+ }
+ if ("throw" in result) {
+ result.throw = dbg.adoptDebuggeeValue(result.throw);
+ }
+ }
+ return result;
+ } finally {
+ // We need to be absolutely sure that the sideeffect-free debugger's
+ // debuggees are removed because otherwise we risk them terminating
+ // execution of later code in the case of unexpected exceptions.
+ if (noSideEffectDebugger) {
+ noSideEffectDebugger.removeAllDebuggees();
+ noSideEffectDebugger.onNativeCall = undefined;
+ }
+ }
+}
+
+/**
+ * Force lexical initialization for let/const variables declared in a throwing expression.
+ * By spec, a lexical declaration is added to the *page-visible* global lexical environment
+ * for those variables, meaning they can't be redeclared (See Bug 1246215).
+ *
+ * This function gets the AST of the throwing expression to collect all the let/const
+ * declarations and call `forceLexicalInitializationByName`, which will initialize them
+ * to undefined, making it possible for them to be redeclared.
+ *
+ * @param {DebuggerObject} dbgGlobal
+ * @param {String} string: The expression that was evaluated and threw
+ * @returns
+ */
+function forceLexicalInitForVariableDeclarationsInThrowingExpression(
+ dbgGlobal,
+ string
+) {
+ // Reflect is not usable in workers, so return early to avoid logging an error
+ // to the console when loading it.
+ if (isWorker) {
+ return;
+ }
+
+ let ast;
+ // Parse errors will raise an exception. We can/should ignore the error
+ // since it's already being handled elsewhere and we are only interested
+ // in initializing bindings.
+ try {
+ ast = lazy.Reflect.parse(string);
+ } catch (e) {
+ return;
+ }
+
+ try {
+ for (const line of ast.body) {
+ // Only let and const declarations put bindings into an
+ // "initializing" state.
+ if (!(line.kind == "let" || line.kind == "const")) {
+ continue;
+ }
+
+ const identifiers = [];
+ for (const decl of line.declarations) {
+ switch (decl.id.type) {
+ case "Identifier":
+ // let foo = bar;
+ identifiers.push(decl.id.name);
+ break;
+ case "ArrayPattern":
+ // let [foo, bar] = [1, 2];
+ // let [foo=99, bar] = [1, 2];
+ for (const e of decl.id.elements) {
+ if (e.type == "Identifier") {
+ identifiers.push(e.name);
+ } else if (e.type == "AssignmentExpression") {
+ identifiers.push(e.left.name);
+ }
+ }
+ break;
+ case "ObjectPattern":
+ // let {bilbo, my} = {bilbo: "baggins", my: "precious"};
+ // let {blah: foo} = {blah: yabba()}
+ // let {blah: foo=99} = {blah: yabba()}
+ for (const prop of decl.id.properties) {
+ // key
+ if (prop.key?.type == "Identifier") {
+ identifiers.push(prop.key.name);
+ }
+ // value
+ if (prop.value?.type == "Identifier") {
+ identifiers.push(prop.value.name);
+ } else if (prop.value?.type == "AssignmentExpression") {
+ identifiers.push(prop.value.left.name);
+ } else if (prop.type === "SpreadExpression") {
+ identifiers.push(prop.expression.name);
+ }
+ }
+ break;
+ }
+ }
+
+ for (const name of identifiers) {
+ dbgGlobal.forceLexicalInitializationByName(name);
+ }
+ }
+ } catch (ex) {
+ console.error(
+ "Error in forceLexicalInitForVariableDeclarationsInThrowingExpression:",
+ ex
+ );
+ }
+}
+
+/**
+ * Creates a side-effect-free Debugger instance.
+ *
+ * @param {Debugger} targetActorDbg
+ * The target actor's dbg object, crafted by make-debugger.js module.
+ * @return {Debugger}
+ * Side-effect-free Debugger instance.
+ */
+function makeSideeffectFreeDebugger(targetActorDbg) {
+ // Populate the cached Map once before the evaluation
+ ensureSideEffectFreeNatives();
+
+ // Note: It is critical for debuggee performance that we implement all of
+ // this debuggee tracking logic with a separate Debugger instance.
+ // Bug 1617666 arises otherwise if we set an onEnterFrame hook on the
+ // existing debugger object and then later clear it.
+ //
+ // Also note that we aren't registering any global to this debugger.
+ // We will only adopt values into it: the paused frame (if any) or the
+ // target's global (when not paused).
+ const dbg = new Debugger();
+
+ // Special flag in order to ensure that any evaluation or call being
+ // made via this debugger will be ignored by all debuggers except this one.
+ dbg.exclusiveDebuggerOnEval = true;
+
+ // We need to register all target actor's globals.
+ // In most cases, this will be only one global, except for the browser toolbox,
+ // where process target actors may interact with many.
+ // On the browser toolbox, we may have many debuggees and this is important to register
+ // them in order to detect native call made from/to these others globals.
+ for (const global of targetActorDbg.findDebuggees()) {
+ try {
+ dbg.addDebuggee(global);
+ } catch (e) {
+ // Ignore the following exception which can happen for some globals in the browser toolbox
+ if (
+ !e.message.includes(
+ "debugger and debuggee must be in different compartments"
+ )
+ ) {
+ throw e;
+ }
+ }
+ }
+
+ const timeoutDuration = 100;
+ const endTime = Date.now() + timeoutDuration;
+ let count = 0;
+ function shouldCancel() {
+ // To keep the evaled code as quick as possible, we avoid querying the
+ // current time on ever single step and instead check every 100 steps
+ // as an arbitrary count that seemed to be "often enough".
+ return ++count % 100 === 0 && Date.now() > endTime;
+ }
+
+ const executedScripts = new Set();
+ const handler = {
+ hit: () => null,
+ };
+ dbg.onEnterFrame = frame => {
+ if (shouldCancel()) {
+ return null;
+ }
+ frame.onStep = () => {
+ if (shouldCancel()) {
+ return null;
+ }
+ return undefined;
+ };
+
+ const script = frame.script;
+
+ if (executedScripts.has(script)) {
+ return undefined;
+ }
+ executedScripts.add(script);
+
+ const offsets = script.getEffectfulOffsets();
+ for (const offset of offsets) {
+ script.setBreakpoint(offset, handler);
+ }
+
+ return undefined;
+ };
+
+ // The debugger only calls onNativeCall handlers on the debugger that is
+ // explicitly calling either eval, DebuggerObject.apply or DebuggerObject.call,
+ // so we need to add this hook on "dbg" even though the rest of our hooks work via "newDbg".
+ const { SIDE_EFFECT_FREE } = WebConsoleCommandsManager;
+ dbg.onNativeCall = (callee, reason) => {
+ try {
+ // Setters are always effectful. Natives called normally or called via
+ // getters are handled with an allowlist.
+ if (
+ (reason == "get" || reason == "call") &&
+ nativeIsEagerlyEvaluateable(callee)
+ ) {
+ // Returning undefined causes execution to continue normally.
+ return undefined;
+ }
+ } catch (err) {
+ DevToolsUtils.reportException(
+ "evalWithDebugger onNativeCall",
+ new Error("Unable to validate native function against allowlist")
+ );
+ }
+
+ // The WebConsole Commands manager will use Cu.exportFunction which will force
+ // to call a native method which is hard to identify.
+ // getEvalResult will flag those getter methods with a magic attribute.
+ if (
+ reason == "call" &&
+ callee.unsafeDereference().isSideEffectFree === SIDE_EFFECT_FREE
+ ) {
+ // Returning undefined causes execution to continue normally.
+ return undefined;
+ }
+
+ // Returning null terminates the current evaluation.
+ return null;
+ };
+
+ return dbg;
+}
+
+// Native functions which are considered to be side effect free.
+let gSideEffectFreeNatives; // string => Array(Function)
+
+/**
+ * Generate gSideEffectFreeNatives map.
+ */
+function ensureSideEffectFreeNatives() {
+ if (gSideEffectFreeNatives) {
+ return;
+ }
+
+ const { natives: domNatives } = eagerFunctionAllowlist;
+
+ const natives = [
+ ...eagerEcmaAllowlist.functions,
+ ...eagerEcmaAllowlist.getters,
+
+ // Pull in all of the non-ECMAScript native functions that we want to
+ // allow as well.
+ ...domNatives,
+ ];
+
+ const map = new Map();
+ for (const n of natives) {
+ if (!map.has(n.name)) {
+ map.set(n.name, []);
+ }
+ map.get(n.name).push(n);
+ }
+
+ gSideEffectFreeNatives = map;
+}
+
+function nativeIsEagerlyEvaluateable(fn) {
+ if (fn.isBoundFunction) {
+ fn = fn.boundTargetFunction;
+ }
+
+ // 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;
+ }
+
+ // Natives with certain names are always considered side effect free.
+ switch (fn.name) {
+ case "toString":
+ case "toLocaleString":
+ case "valueOf":
+ return true;
+ }
+
+ const natives = gSideEffectFreeNatives.get(fn.name);
+ return natives && natives.some(n => fn.isSameNative(n));
+}
+
+function updateConsoleInputEvaluation(dbg, webConsole) {
+ // Adopt webConsole._lastConsoleInputEvaluation value in the new debugger,
+ // to prevent "Debugger.Object belongs to a different Debugger" exceptions
+ // related to the $_ bindings if the debugger object is changed from the
+ // last evaluation.
+ if (webConsole._lastConsoleInputEvaluation) {
+ webConsole._lastConsoleInputEvaluation = dbg.adoptDebuggeeValue(
+ webConsole._lastConsoleInputEvaluation
+ );
+ }
+}
+
+function getEvalInput(string, bindings) {
+ const trimmedString = string.trim();
+ // Add easter egg for console.mihai().
+ if (
+ trimmedString == "console.mihai()" ||
+ trimmedString == "console.mihai();"
+ ) {
+ return '"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/"';
+ }
+ return string;
+}
+
+function getFrameDbg(options, webConsole) {
+ if (!options.frameActor) {
+ return { frame: null, dbg: webConsole.dbg };
+ }
+ // Find the Debugger.Frame of the given FrameActor.
+ const frameActor = webConsole.conn.getActor(options.frameActor);
+ if (frameActor) {
+ // If we've been given a frame actor in whose scope we should evaluate the
+ // expression, be sure to use that frame's Debugger (that is, the JavaScript
+ // debugger's Debugger) for the whole operation, not the console's Debugger.
+ // (One Debugger will treat a different Debugger's Debugger.Object instances
+ // as ordinary objects, not as references to be followed, so mixing
+ // debuggers causes strange behaviors.)
+ return { frame: frameActor.frame, dbg: frameActor.threadActor.dbg };
+ }
+ return DevToolsUtils.reportException(
+ "evalWithDebugger",
+ Error("The frame actor was not found: " + options.frameActor)
+ );
+}
+
+/**
+ * Get debugger object for given debugger and Web Console.
+ *
+ * @param object options
+ * See the `options` parameter of evalWithDebugger
+ * @param {Debugger} dbg
+ * Debugger object
+ * @param {WebConsoleActor} webConsole
+ * A reference to a webconsole actor which is used to get the target
+ * eval global and optionally the target actor
+ * @return object
+ * An object that holds the following properties:
+ * - bindSelf: (optional) the self object for the evaluation
+ * - dbgGlobal: the global object reference in the debugger
+ */
+function getDbgGlobal(options, dbg, webConsole) {
+ let evalGlobal = webConsole.evalGlobal;
+
+ if (options.innerWindowID) {
+ const window = Services.wm.getCurrentInnerWindowWithId(
+ options.innerWindowID
+ );
+
+ if (window) {
+ evalGlobal = window;
+ }
+ }
+
+ const dbgGlobal = dbg.makeGlobalObjectReference(evalGlobal);
+
+ // If we have an object to bind to |_self|, create a Debugger.Object
+ // referring to that object, belonging to dbg.
+ if (!options.selectedObjectActor) {
+ return { bindSelf: null, dbgGlobal };
+ }
+
+ // For objects related to console messages, they will be registered under the Target Actor
+ // instead of the WebConsoleActor. That's because console messages are resources and all resources
+ // are emitted by the Target Actor.
+ const actor =
+ webConsole.getActorByID(options.selectedObjectActor) ||
+ webConsole.parentActor.getActorByID(options.selectedObjectActor);
+
+ if (!actor) {
+ return { bindSelf: null, dbgGlobal };
+ }
+
+ const jsVal = actor instanceof LongStringActor ? actor.str : actor.rawValue();
+ if (!isObject(jsVal)) {
+ return { bindSelf: jsVal, dbgGlobal };
+ }
+
+ // If we use the makeDebuggeeValue method of jsVal's own global, then
+ // we'll get a D.O that sees jsVal as viewed from its own compartment -
+ // that is, without wrappers. The evalWithBindings call will then wrap
+ // jsVal appropriately for the evaluation compartment.
+ const bindSelf = dbgGlobal.makeDebuggeeValue(jsVal);
+ return { bindSelf, dbgGlobal };
+}
diff --git a/devtools/server/actors/webconsole/listeners/console-api.js b/devtools/server/actors/webconsole/listeners/console-api.js
new file mode 100644
index 0000000000..3e5d0bc52f
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-api.js
@@ -0,0 +1,255 @@
+/* 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 {
+ CONSOLE_WORKER_IDS,
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+// The window.console API observer
+
+/**
+ * The window.console API observer. This allows the window.console API messages
+ * to be sent to the remote Web Console instance.
+ *
+ * @constructor
+ * @param nsIDOMWindow window
+ * Optional - the window object for which we are created. This is used
+ * for filtering out messages that belong to other windows.
+ * @param Function handler
+ * This function is invoked with one argument, the Console API message that comes
+ * from the observer service, whenever a relevant console API call is received.
+ * @param object filteringOptions
+ * Optional - The filteringOptions that this listener should listen to:
+ * - addonId: filter console messages based on the addonId.
+ * - excludeMessagesBoundToWindow: Set to true to filter out messages that
+ * are bound to a specific window.
+ * - matchExactWindow: Set to true to match the messages on a specific window (when
+ * `window` is defined) and not on the whole window tree.
+ */
+class ConsoleAPIListener {
+ constructor(
+ window,
+ handler,
+ { addonId, excludeMessagesBoundToWindow, matchExactWindow } = {}
+ ) {
+ this.window = window;
+ this.handler = handler;
+ this.addonId = addonId;
+ this.excludeMessagesBoundToWindow = excludeMessagesBoundToWindow;
+ this.matchExactWindow = matchExactWindow;
+ if (this.window) {
+ this.innerWindowId = WebConsoleUtils.getInnerWindowId(this.window);
+ }
+ }
+
+ QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]);
+
+ /**
+ * The content window for which we listen to window.console API calls.
+ * @type nsIDOMWindow
+ */
+ window = null;
+
+ /**
+ * The function which is notified of window.console API calls. It is invoked with one
+ * argument: the console API call object that comes from the ConsoleAPIStorage service.
+ *
+ * @type function
+ */
+ handler = null;
+
+ /**
+ * The addonId that we listen for. If not null then only messages from this
+ * console will be returned.
+ */
+ addonId = null;
+
+ /**
+ * Initialize the window.console API listener.
+ */
+ init() {
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+
+ // Note that the listener is process-wide. We will filter the messages as
+ // needed, see onConsoleAPILogEvent().
+ this.onConsoleAPILogEvent = this.onConsoleAPILogEvent.bind(this);
+ ConsoleAPIStorage.addLogEventListener(
+ this.onConsoleAPILogEvent,
+ // We create a principal here to get the privileged principal of this
+ // script. Note that this is importantly *NOT* the principal of the
+ // content we are observing, as that would not have access to the
+ // message object created in ConsoleAPIStorage.jsm's scope.
+ Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
+ );
+ }
+
+ /**
+ * The console API message listener. When messages are received from the
+ * ConsoleAPIStorage service we forward them to the remote Web Console instance.
+ *
+ * @param object message
+ * The message object receives from the ConsoleAPIStorage service.
+ */
+ onConsoleAPILogEvent(message) {
+ if (!this.handler) {
+ return;
+ }
+
+ // Here, wrappedJSObject is not a security wrapper but a property defined
+ // by the XPCOM component which allows us to unwrap the XPCOM interface and
+ // access the underlying JSObject.
+ const apiMessage = message.wrappedJSObject;
+
+ if (!this.isMessageRelevant(apiMessage)) {
+ return;
+ }
+
+ this.handler(apiMessage);
+ }
+
+ /**
+ * Given a message, return true if this window should show it and false
+ * if it should be ignored.
+ *
+ * @param message
+ * The message from the Storage Service
+ * @return bool
+ * Do we care about this message?
+ */
+ isMessageRelevant(message) {
+ const workerType = WebConsoleUtils.getWorkerType(message);
+
+ if (this.window && workerType === "ServiceWorker") {
+ // For messages from Service Workers, message.ID is the
+ // scope, which can be used to determine whether it's controlling
+ // a window.
+ const scope = message.ID;
+
+ if (!this.window.shouldReportForServiceWorkerScope(scope)) {
+ return false;
+ }
+ }
+
+ // innerID can be of different type:
+ // - a number if the message is bound to a specific window
+ // - a worker type ([Shared|Service]Worker) if the message comes from a worker
+ // - a JSM filename
+ // if we want to filter on a specific window, ignore all non-worker messages that
+ // don't have a proper window id (for now, we receive the worker messages from the
+ // main process so we still want to get them, although their innerID isn't a number).
+ if (!workerType && typeof message.innerID !== "number" && this.window) {
+ return false;
+ }
+
+ // Don't show ChromeWorker messages on WindowGlobal targets
+ if (workerType && this.window && message.chromeContext) {
+ return false;
+ }
+
+ if (typeof message.innerID == "number") {
+ if (
+ this.excludeMessagesBoundToWindow &&
+ // If innerID is 0, the message isn't actually bound to a window.
+ message.innerID
+ ) {
+ return false;
+ }
+
+ if (this.window) {
+ const matchesWindow = this.matchExactWindow
+ ? this.innerWindowId === message.innerID
+ : WebConsoleUtils.getInnerWindowIDsForFrames(this.window).includes(
+ message.innerID
+ );
+
+ if (!matchesWindow) {
+ // Not the same window!
+ return false;
+ }
+ }
+ }
+
+ if (this.addonId) {
+ // ConsoleAPI.jsm messages contains a consoleID, (and it is currently
+ // used in Addon SDK add-ons), the standard 'console' object
+ // (which is used in regular webpages and in WebExtensions pages)
+ // contains the originAttributes of the source document principal.
+
+ // Filtering based on the originAttributes used by
+ // the Console API object.
+ if (message.addonId == this.addonId) {
+ return true;
+ }
+
+ // Filtering based on the old-style consoleID property used by
+ // the legacy Console JSM module.
+ if (message.consoleID && message.consoleID == `addon/${this.addonId}`) {
+ return true;
+ }
+
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the cached messages for the current inner window and its (i)frames.
+ *
+ * @param boolean [includePrivate=false]
+ * Tells if you want to also retrieve messages coming from private
+ * windows. Defaults to false.
+ * @return array
+ * The array of cached messages.
+ */
+ getCachedMessages(includePrivate = false) {
+ let messages = [];
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+
+ // if !this.window, we're in a browser console. Retrieve all events
+ // for filtering based on privacy.
+ if (!this.window) {
+ messages = ConsoleAPIStorage.getEvents();
+ } else if (this.matchExactWindow) {
+ messages = ConsoleAPIStorage.getEvents(this.innerWindowId);
+ } else {
+ WebConsoleUtils.getInnerWindowIDsForFrames(this.window).forEach(id => {
+ messages = messages.concat(ConsoleAPIStorage.getEvents(id));
+ });
+ }
+
+ CONSOLE_WORKER_IDS.forEach(id => {
+ messages = messages.concat(ConsoleAPIStorage.getEvents(id));
+ });
+
+ messages = messages.filter(msg => {
+ return this.isMessageRelevant(msg);
+ });
+
+ if (includePrivate) {
+ return messages;
+ }
+
+ return messages.filter(m => !m.private);
+ }
+
+ /**
+ * Destroy the console API listener.
+ */
+ destroy() {
+ const ConsoleAPIStorage = Cc[
+ "@mozilla.org/consoleAPI-storage;1"
+ ].getService(Ci.nsIConsoleAPIStorage);
+ ConsoleAPIStorage.removeLogEventListener(this.onConsoleAPILogEvent);
+ this.window = this.handler = null;
+ }
+}
+exports.ConsoleAPIListener = ConsoleAPIListener;
diff --git a/devtools/server/actors/webconsole/listeners/console-file-activity.js b/devtools/server/actors/webconsole/listeners/console-file-activity.js
new file mode 100644
index 0000000000..7e5ae0d1a8
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-file-activity.js
@@ -0,0 +1,126 @@
+/* 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";
+
+/**
+ * A WebProgressListener that listens for file loads.
+ *
+ * @constructor
+ * @param object window
+ * The window for which we need to track file loads.
+ * @param object owner
+ * The listener owner which needs to implement:
+ * - onFileActivity(aFileURI)
+ */
+function ConsoleFileActivityListener(window, owner) {
+ this.window = window;
+ this.owner = owner;
+}
+exports.ConsoleFileActivityListener = ConsoleFileActivityListener;
+
+ConsoleFileActivityListener.prototype = {
+ /**
+ * Tells if the console progress listener is initialized or not.
+ * @private
+ * @type boolean
+ */
+ _initialized: false,
+
+ _webProgress: null,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+
+ /**
+ * Initialize the ConsoleFileActivityListener.
+ * @private
+ */
+ _init() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._webProgress = this.window.docShell.QueryInterface(Ci.nsIWebProgress);
+ this._webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_ALL
+ );
+
+ this._initialized = true;
+ },
+
+ /**
+ * Start a monitor/tracker related to the current nsIWebProgressListener
+ * instance.
+ */
+ startMonitor() {
+ this._init();
+ },
+
+ /**
+ * Stop monitoring.
+ */
+ stopMonitor() {
+ this.destroy();
+ },
+
+ onStateChange(progress, request, state, status) {
+ if (!this.owner) {
+ return;
+ }
+
+ this._checkFileActivity(progress, request, state, status);
+ },
+
+ /**
+ * Check if there is any file load, given the arguments of
+ * nsIWebProgressListener.onStateChange. If the state change tells that a file
+ * URI has been loaded, then the remote Web Console instance is notified.
+ * @private
+ */
+ _checkFileActivity(progress, request, state, status) {
+ if (!(state & Ci.nsIWebProgressListener.STATE_START)) {
+ return;
+ }
+
+ let uri = null;
+ if (request instanceof Ci.imgIRequest) {
+ const imgIRequest = request.QueryInterface(Ci.imgIRequest);
+ uri = imgIRequest.URI;
+ } else if (request instanceof Ci.nsIChannel) {
+ const nsIChannel = request.QueryInterface(Ci.nsIChannel);
+ uri = nsIChannel.URI;
+ }
+
+ if (!uri || (!uri.schemeIs("file") && !uri.schemeIs("ftp"))) {
+ return;
+ }
+
+ this.owner.onFileActivity(uri.spec);
+ },
+
+ /**
+ * Destroy the ConsoleFileActivityListener.
+ */
+ destroy() {
+ if (!this._initialized) {
+ return;
+ }
+
+ this._initialized = false;
+
+ try {
+ this._webProgress.removeProgressListener(this);
+ } catch (ex) {
+ // This can throw during browser shutdown.
+ }
+
+ this._webProgress = null;
+ this.window = null;
+ this.owner = null;
+ },
+};
diff --git a/devtools/server/actors/webconsole/listeners/console-reflow.js b/devtools/server/actors/webconsole/listeners/console-reflow.js
new file mode 100644
index 0000000000..8404a70b4a
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-reflow.js
@@ -0,0 +1,90 @@
+/* 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";
+
+/**
+ * A ReflowObserver that listens for reflow events from the page.
+ * Implements nsIReflowObserver.
+ *
+ * @constructor
+ * @param object window
+ * The window for which we need to track reflow.
+ * @param object owner
+ * The listener owner which needs to implement:
+ * - onReflowActivity(reflowInfo)
+ */
+
+function ConsoleReflowListener(window, listener) {
+ this.docshell = window.docShell;
+ this.listener = listener;
+ this.docshell.addWeakReflowObserver(this);
+}
+
+exports.ConsoleReflowListener = ConsoleReflowListener;
+
+ConsoleReflowListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIReflowObserver",
+ "nsISupportsWeakReference",
+ ]),
+ docshell: null,
+ listener: null,
+
+ /**
+ * Forward reflow event to listener.
+ *
+ * @param DOMHighResTimeStamp start
+ * @param DOMHighResTimeStamp end
+ * @param boolean interruptible
+ */
+ sendReflow(start, end, interruptible) {
+ const frame = Components.stack.caller.caller;
+
+ let filename = frame ? frame.filename : null;
+
+ if (filename) {
+ // Because filename could be of the form "xxx.js -> xxx.js -> xxx.js",
+ // we only take the last part.
+ filename = filename.split(" ").pop();
+ }
+
+ this.listener.onReflowActivity({
+ interruptible,
+ start,
+ end,
+ sourceURL: filename,
+ sourceLine: frame ? frame.lineNumber : null,
+ functionName: frame ? frame.name : null,
+ });
+ },
+
+ /**
+ * On uninterruptible reflow
+ *
+ * @param DOMHighResTimeStamp start
+ * @param DOMHighResTimeStamp end
+ */
+ reflow(start, end) {
+ this.sendReflow(start, end, false);
+ },
+
+ /**
+ * On interruptible reflow
+ *
+ * @param DOMHighResTimeStamp start
+ * @param DOMHighResTimeStamp end
+ */
+ reflowInterruptible(start, end) {
+ this.sendReflow(start, end, true);
+ },
+
+ /**
+ * Unregister listener.
+ */
+ destroy() {
+ this.docshell.removeWeakReflowObserver(this);
+ this.listener = this.docshell = null;
+ },
+};
diff --git a/devtools/server/actors/webconsole/listeners/console-service.js b/devtools/server/actors/webconsole/listeners/console-service.js
new file mode 100644
index 0000000000..11ced5611f
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/console-service.js
@@ -0,0 +1,193 @@
+/* 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 {
+ isWindowIncluded,
+} = require("resource://devtools/shared/layout/utils.js");
+const {
+ WebConsoleUtils,
+} = require("resource://devtools/server/actors/webconsole/utils.js");
+
+// The page errors listener
+
+/**
+ * The nsIConsoleService listener. This is used to send all of the console
+ * messages (JavaScript, CSS and more) to the remote Web Console instance.
+ *
+ * @constructor
+ * @param nsIDOMWindow [window]
+ * Optional - the window object for which we are created. This is used
+ * for filtering out messages that belong to other windows.
+ * @param Function handler
+ * This function is invoked with one argument, the nsIConsoleMessage, whenever a
+ * relevant message is received.
+ * @param object filteringOptions
+ * Optional - The filteringOptions that this listener should listen to:
+ * - matchExactWindow: Set to true to match the messages on a specific window (when
+ * `window` is defined) and not on the whole window tree.
+ */
+class ConsoleServiceListener {
+ constructor(window, handler, { matchExactWindow } = {}) {
+ this.window = window;
+ this.handler = handler;
+ this.matchExactWindow = matchExactWindow;
+ }
+
+ QueryInterface = ChromeUtils.generateQI([Ci.nsIConsoleListener]);
+
+ /**
+ * The content window for which we listen to page errors.
+ * @type nsIDOMWindow
+ */
+ window = null;
+
+ /**
+ * The function which is notified of messages from the console service.
+ * @type function
+ */
+ handler = null;
+
+ /**
+ * Initialize the nsIConsoleService listener.
+ */
+ init() {
+ Services.console.registerListener(this);
+ }
+
+ /**
+ * The nsIConsoleService observer. This method takes all the script error
+ * messages belonging to the current window and sends them to the remote Web
+ * Console instance.
+ *
+ * @param nsIConsoleMessage message
+ * The message object coming from the nsIConsoleService.
+ */
+ observe(message) {
+ if (!this.handler) {
+ return;
+ }
+
+ if (this.window) {
+ if (
+ !(message instanceof Ci.nsIScriptError) ||
+ !message.outerWindowID ||
+ !this.isCategoryAllowed(message.category)
+ ) {
+ return;
+ }
+
+ const errorWindow = Services.wm.getOuterWindowWithId(
+ message.outerWindowID
+ );
+
+ if (!errorWindow) {
+ return;
+ }
+
+ if (this.matchExactWindow && this.window !== errorWindow) {
+ return;
+ }
+
+ if (!isWindowIncluded(this.window, errorWindow)) {
+ return;
+ }
+ }
+
+ // Don't display messages triggered by eager evaluation.
+ if (message.sourceName === "debugger eager eval code") {
+ return;
+ }
+ this.handler(message);
+ }
+
+ /**
+ * Check if the given message category is allowed to be tracked or not.
+ * We ignore chrome-originating errors as we only care about content.
+ *
+ * @param string category
+ * The message category you want to check.
+ * @return boolean
+ * True if the category is allowed to be logged, false otherwise.
+ */
+ isCategoryAllowed(category) {
+ if (!category) {
+ return false;
+ }
+
+ switch (category) {
+ case "XPConnect JavaScript":
+ case "component javascript":
+ case "chrome javascript":
+ case "chrome registration":
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the cached page errors for the current inner window and its (i)frames.
+ *
+ * @param boolean [includePrivate=false]
+ * Tells if you want to also retrieve messages coming from private
+ * windows. Defaults to false.
+ * @return array
+ * The array of cached messages. Each element is an nsIScriptError or
+ * an nsIConsoleMessage
+ */
+ getCachedMessages(includePrivate = false) {
+ const errors = Services.console.getMessageArray() || [];
+
+ // if !this.window, we're in a browser console. Still need to filter
+ // private messages.
+ if (!this.window) {
+ return errors.filter(error => {
+ if (error instanceof Ci.nsIScriptError) {
+ if (!includePrivate && error.isFromPrivateWindow) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ }
+
+ const ids = this.matchExactWindow
+ ? [WebConsoleUtils.getInnerWindowId(this.window)]
+ : WebConsoleUtils.getInnerWindowIDsForFrames(this.window);
+
+ return errors.filter(error => {
+ if (error instanceof Ci.nsIScriptError) {
+ if (!includePrivate && error.isFromPrivateWindow) {
+ return false;
+ }
+ if (
+ ids &&
+ (!ids.includes(error.innerWindowID) ||
+ !this.isCategoryAllowed(error.category))
+ ) {
+ return false;
+ }
+ } else if (ids?.[0]) {
+ // If this is not an nsIScriptError and we need to do window-based
+ // filtering we skip this message.
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * Remove the nsIConsoleService listener.
+ */
+ destroy() {
+ Services.console.unregisterListener(this);
+ this.handler = this.window = null;
+ }
+}
+
+exports.ConsoleServiceListener = ConsoleServiceListener;
diff --git a/devtools/server/actors/webconsole/listeners/document-events.js b/devtools/server/actors/webconsole/listeners/document-events.js
new file mode 100644
index 0000000000..1c1f926436
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/document-events.js
@@ -0,0 +1,247 @@
+/* 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/. */
+
+// XPCNativeWrapper is not defined globally in ESLint as it may be going away.
+// See bug 1481337.
+/* global XPCNativeWrapper */
+
+"use strict";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+/**
+ * About "navigationStart - ${WILL_NAVIGATE_TIME_SHIFT}ms":
+ * Unfortunately dom-loading's navigationStart timestamp is older than the navigationStart we receive from will-navigate.
+ *
+ * That's because we record `navigationStart` before will-navigate code is called.
+ * And will-navigate code don't have access to performance.timing.navigationStart that dom-loading is using.
+ * The `performance.timing.navigationStart` is recorded earlier from `DocumentLoadListener.SetNavigating`, here:
+ * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#907-908
+ * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#820-823
+ * While this function is being called via `nsIWebProgressListener.onStateChange`, here:
+ * https://searchfox.org/mozilla-central/rev/9b430bb1a11d7152cab2af4574f451ffb906b052/netwerk/ipc/DocumentLoadListener.cpp#934-939
+ * And we record the navigationStart timestamp from onStateChange by using Date.now(), which is more recent
+ * than performance.timing.navigationStart.
+ *
+ * We do this workaround because all DOCUMENT_EVENT comes with a "time" timestamp.
+ * Each event relates to a particular event in the lifecycle of documents and are supposed to follow a particular order:
+ * - will-navigate (on the previous target)
+ * - dom-loading (on the new target)
+ * - dom-interactive
+ * - dom-complete
+ * And some tests are asserting this.
+ */
+const WILL_NAVIGATE_TIME_SHIFT = 20;
+exports.WILL_NAVIGATE_TIME_SHIFT = WILL_NAVIGATE_TIME_SHIFT;
+
+/**
+ * Forward `DOMContentLoaded` and `load` events with precise timing
+ * of when events happened according to window.performance numbers.
+ *
+ * @constructor
+ * @param WindowGlobalTarget targetActor
+ */
+function DocumentEventsListener(targetActor) {
+ this.targetActor = targetActor;
+
+ EventEmitter.decorate(this);
+ this.onWillNavigate = this.onWillNavigate.bind(this);
+ this.onWindowReady = this.onWindowReady.bind(this);
+ this.onContentLoaded = this.onContentLoaded.bind(this);
+ this.onLoad = this.onLoad.bind(this);
+}
+
+exports.DocumentEventsListener = DocumentEventsListener;
+
+DocumentEventsListener.prototype = {
+ listen() {
+ // When EFT is enabled, the Target Actor won't dispatch any will-navigate/window-ready event
+ // Instead listen to WebProgressListener interface directly, so that we can later drop the whole
+ // DebuggerProgressListener interface in favor of this class.
+ // Also, do not wait for "load" event as it can be blocked in case of error during the load
+ // or when calling window.stop(). We still want to emit "dom-complete" in these scenarios.
+ if (this.targetActor.ignoreSubFrames) {
+ // Ignore listening to anything if the page is already fully loaded.
+ // This can be the case when opening DevTools against an already loaded page
+ // or when doing bfcache navigations.
+ if (this.targetActor.window.document.readyState != "complete") {
+ this.webProgress = this.targetActor.docShell
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress);
+ this.webProgress.addProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ }
+ } else {
+ // Listen to will-navigate and do not emit a fake one as we only care about upcoming navigation
+ this.targetActor.on("will-navigate", this.onWillNavigate);
+
+ // Listen to window-ready and then fake one in order to notify about dom-loading for the existing document
+ this.targetActor.on("window-ready", this.onWindowReady);
+ }
+ // The target actor already emitted a window-ready for the top document when instantiating.
+ // So fake one for the top document right away.
+ this.onWindowReady({
+ window: this.targetActor.window,
+ isTopLevel: true,
+ });
+ },
+
+ onWillNavigate({
+ window,
+ isTopLevel,
+ newURI,
+ navigationStart,
+ isFrameSwitching,
+ }) {
+ // Ignore iframes
+ if (!isTopLevel) {
+ return;
+ }
+
+ this.emit("will-navigate", {
+ time: navigationStart - WILL_NAVIGATE_TIME_SHIFT,
+ newURI,
+ isFrameSwitching,
+ });
+ },
+
+ onWindowReady({ window, isTopLevel, isFrameSwitching }) {
+ // Ignore iframes
+ if (!isTopLevel) {
+ return;
+ }
+
+ const time = window.performance.timing.navigationStart;
+
+ this.emit("dom-loading", {
+ time,
+ isFrameSwitching,
+ });
+
+ const { readyState } = window.document;
+ if (readyState != "interactive" && readyState != "complete") {
+ // When EFT is enabled, we track this event via the WebProgressListener interface.
+ if (!this.targetActor.ignoreSubFrames) {
+ window.addEventListener(
+ "DOMContentLoaded",
+ e => this.onContentLoaded(e, isFrameSwitching),
+ {
+ once: true,
+ }
+ );
+ }
+ } else {
+ this.onContentLoaded({ target: window.document }, isFrameSwitching);
+ }
+ if (readyState != "complete") {
+ // When EFT is enabled, we track the load event via the WebProgressListener interface.
+ if (!this.targetActor.ignoreSubFrames) {
+ window.addEventListener("load", e => this.onLoad(e, isFrameSwitching), {
+ once: true,
+ });
+ }
+ } else {
+ this.onLoad({ target: window.document }, isFrameSwitching);
+ }
+ },
+
+ onContentLoaded(event, isFrameSwitching) {
+ if (this.destroyed) {
+ return;
+ }
+ // milliseconds since the UNIX epoch, when the parser finished its work
+ // on the main document, that is when its Document.readyState changes to
+ // 'interactive' and the corresponding readystatechange event is thrown
+ const window = event.target.defaultView;
+ const time = window.performance.timing.domInteractive;
+ this.emit("dom-interactive", { time, isFrameSwitching });
+ },
+
+ onLoad(event, isFrameSwitching) {
+ if (this.destroyed) {
+ return;
+ }
+ // milliseconds since the UNIX epoch, when the parser finished its work
+ // on the main document, that is when its Document.readyState changes to
+ // 'complete' and the corresponding readystatechange event is thrown
+ const window = event.target.defaultView;
+ const time = window.performance.timing.domComplete;
+ this.emit("dom-complete", {
+ time,
+ isFrameSwitching,
+ hasNativeConsoleAPI: this.hasNativeConsoleAPI(window),
+ });
+ },
+
+ onStateChange(progress, request, flag, status) {
+ progress.QueryInterface(Ci.nsIDocShell);
+ // Ignore destroyed, or progress for same-process iframes
+ if (progress.isBeingDestroyed() || progress != this.webProgress) {
+ return;
+ }
+
+ const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP;
+ const isDocument = flag & Ci.nsIWebProgressListener.STATE_IS_DOCUMENT;
+ const isWindow = flag & Ci.nsIWebProgressListener.STATE_IS_WINDOW;
+ const window = progress.DOMWindow;
+ if (isDocument && isStop) {
+ const time = window.performance.timing.domInteractive;
+ this.emit("dom-interactive", { time });
+ } else if (isWindow && isStop) {
+ const time = window.performance.timing.domComplete;
+ this.emit("dom-complete", {
+ time,
+ hasNativeConsoleAPI: this.hasNativeConsoleAPI(window),
+ });
+ }
+ },
+
+ /**
+ * Tells if the window.console object is native or overwritten by script in
+ * the page.
+ *
+ * @param nsIDOMWindow window
+ * The window object you want to check.
+ * @return boolean
+ * True if the window.console object is native, or false otherwise.
+ */
+ hasNativeConsoleAPI(window) {
+ let isNative = false;
+ try {
+ // We are very explicitly examining the "console" property of
+ // the non-Xrayed object here.
+ const console = window.wrappedJSObject.console;
+ // In xpcshell tests, console ends up being undefined and XPCNativeWrapper
+ // crashes in debug builds.
+ if (console) {
+ isNative = new XPCNativeWrapper(console).IS_NATIVE_CONSOLE === true;
+ }
+ } catch (ex) {
+ // ignore
+ }
+ return isNative;
+ },
+
+ destroy() {
+ // Also use a flag to silent onContentLoad and onLoad events
+ this.destroyed = true;
+ this.targetActor.off("will-navigate", this.onWillNavigate);
+ this.targetActor.off("window-ready", this.onWindowReady);
+ if (this.webProgress) {
+ this.webProgress.removeProgressListener(
+ this,
+ Ci.nsIWebProgress.NOTIFY_STATE_WINDOW |
+ Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
+ );
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+};
diff --git a/devtools/server/actors/webconsole/listeners/moz.build b/devtools/server/actors/webconsole/listeners/moz.build
new file mode 100644
index 0000000000..089de4a087
--- /dev/null
+++ b/devtools/server/actors/webconsole/listeners/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/.
+
+DevToolsModules(
+ "console-api.js",
+ "console-file-activity.js",
+ "console-reflow.js",
+ "console-service.js",
+ "document-events.js",
+)
diff --git a/devtools/server/actors/webconsole/moz.build b/devtools/server/actors/webconsole/moz.build
new file mode 100644
index 0000000000..58d8a70211
--- /dev/null
+++ b/devtools/server/actors/webconsole/moz.build
@@ -0,0 +1,20 @@
+# -*- 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 += [
+ "commands",
+ "listeners",
+]
+
+DevToolsModules(
+ "eager-ecma-allowlist.js",
+ "eager-function-allowlist.js",
+ "eval-with-debugger.js",
+ "utils.js",
+ "webidl-pure-allowlist.js",
+ "webidl-unsafe-getters-names.js",
+ "worker-listeners.js",
+)
diff --git a/devtools/server/actors/webconsole/utils.js b/devtools/server/actors/webconsole/utils.js
new file mode 100644
index 0000000000..2834696944
--- /dev/null
+++ b/devtools/server/actors/webconsole/utils.js
@@ -0,0 +1,160 @@
+/* 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 CONSOLE_WORKER_IDS = (exports.CONSOLE_WORKER_IDS = new Set([
+ "SharedWorker",
+ "ServiceWorker",
+ "Worker",
+]));
+
+var WebConsoleUtils = {
+ /**
+ * Given a message, return one of CONSOLE_WORKER_IDS if it matches
+ * one of those.
+ *
+ * @return string
+ */
+ getWorkerType(message) {
+ const innerID = message?.innerID;
+ return CONSOLE_WORKER_IDS.has(innerID) ? innerID : null;
+ },
+
+ /**
+ * Gets the ID of the inner window of this DOM window.
+ *
+ * @param nsIDOMWindow window
+ * @return integer|null
+ * Inner ID for the given window, null if we can't access it.
+ */
+ getInnerWindowId(window) {
+ // Might throw with SecurityError: Permission denied to access property
+ // "windowGlobalChild" on cross-origin object.
+ try {
+ return window.windowGlobalChild.innerWindowId;
+ } catch (e) {
+ return null;
+ }
+ },
+
+ /**
+ * Recursively gather a list of inner window ids given a
+ * top level window.
+ *
+ * @param nsIDOMWindow window
+ * @return Array
+ * list of inner window ids.
+ */
+ getInnerWindowIDsForFrames(window) {
+ const innerWindowID = this.getInnerWindowId(window);
+ if (innerWindowID === null) {
+ return [];
+ }
+
+ let ids = [innerWindowID];
+
+ if (window.frames) {
+ for (let i = 0; i < window.frames.length; i++) {
+ const frame = window.frames[i];
+ ids = ids.concat(this.getInnerWindowIDsForFrames(frame));
+ }
+ }
+
+ return ids;
+ },
+
+ /**
+ * Create a grip for the given value. If the value is an object,
+ * an object wrapper will be created.
+ *
+ * @param mixed value
+ * The value you want to create a grip for, before sending it to the
+ * client.
+ * @param function objectWrapper
+ * If the value is an object then the objectWrapper function is
+ * invoked to give us an object grip. See this.getObjectGrip().
+ * @return mixed
+ * The value grip.
+ */
+ createValueGrip(value, objectWrapper) {
+ switch (typeof value) {
+ case "boolean":
+ return value;
+ case "string":
+ return objectWrapper(value);
+ case "number":
+ if (value === Infinity) {
+ return { type: "Infinity" };
+ } else if (value === -Infinity) {
+ return { type: "-Infinity" };
+ } else if (Number.isNaN(value)) {
+ return { type: "NaN" };
+ } else if (!value && 1 / value === -Infinity) {
+ return { type: "-0" };
+ }
+ return value;
+ case "undefined":
+ return { type: "undefined" };
+ case "object":
+ if (value === null) {
+ return { type: "null" };
+ }
+ // Fall through.
+ case "function":
+ case "record":
+ case "tuple":
+ return objectWrapper(value);
+ default:
+ console.error(
+ "Failed to provide a grip for value of " + typeof value + ": " + value
+ );
+ return null;
+ }
+ },
+
+ /**
+ * Remove any frames in a stack that are above a debugger-triggered evaluation
+ * and will correspond with devtools server code, which we never want to show
+ * to the user.
+ *
+ * @param array stack
+ * An array of frames, with the topmost first, and each of which has a
+ * 'filename' property.
+ * @return array
+ * An array of stack frames with any devtools server frames removed.
+ * The original array is not modified.
+ */
+ removeFramesAboveDebuggerEval(stack) {
+ const debuggerEvalFilename = "debugger eval code";
+
+ // Remove any frames for server code above the last debugger eval frame.
+ const evalIndex = stack.findIndex(({ filename }, idx, arr) => {
+ const nextFrame = arr[idx + 1];
+ return (
+ filename == debuggerEvalFilename &&
+ (!nextFrame || nextFrame.filename !== debuggerEvalFilename)
+ );
+ });
+ if (evalIndex != -1) {
+ return stack.slice(0, evalIndex + 1);
+ }
+
+ // In some cases (e.g. evaluated expression with SyntaxError), we might not have a
+ // "debugger eval code" frame but still have internal ones. If that's the case, we
+ // return null as the end user shouldn't see those frames.
+ if (
+ stack.some(
+ ({ filename }) =>
+ filename && filename.startsWith("resource://devtools/")
+ )
+ ) {
+ return null;
+ }
+
+ return stack;
+ },
+};
+
+exports.WebConsoleUtils = WebConsoleUtils;
diff --git a/devtools/server/actors/webconsole/webidl-pure-allowlist.js b/devtools/server/actors/webconsole/webidl-pure-allowlist.js
new file mode 100644
index 0000000000..3db5a14da1
--- /dev/null
+++ b/devtools/server/actors/webconsole/webidl-pure-allowlist.js
@@ -0,0 +1,87 @@
+/* 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/. */
+
+// This file is automatically generated by the GenerateDataFromWebIdls.py
+// script. Do not modify it manually.
+"use strict";
+
+module.exports = {
+ DOMTokenList: {
+ prototype: ["item", "contains"],
+ },
+ Document: {
+ prototype: [
+ "getSelection",
+ "hasStorageAccess",
+ "getElementsByTagName",
+ "getElementsByTagNameNS",
+ "getElementsByClassName",
+ "getElementById",
+ "getElementsByName",
+ "querySelector",
+ "querySelectorAll",
+ "createNSResolver",
+ ],
+ },
+ Element: {
+ prototype: [
+ "getAttributeNames",
+ "getAttribute",
+ "getAttributeNS",
+ "hasAttribute",
+ "hasAttributeNS",
+ "hasAttributes",
+ "closest",
+ "matches",
+ "webkitMatchesSelector",
+ "getElementsByTagName",
+ "getElementsByTagNameNS",
+ "getElementsByClassName",
+ "mozMatchesSelector",
+ "querySelector",
+ "querySelectorAll",
+ "getAsFlexContainer",
+ "getGridFragments",
+ "hasGridFragments",
+ "getElementsWithGrid",
+ ],
+ },
+ FormData: {
+ prototype: ["entries", "keys", "values"],
+ },
+ Headers: {
+ prototype: ["entries", "keys", "values"],
+ },
+ Node: {
+ prototype: [
+ "getRootNode",
+ "hasChildNodes",
+ "isSameNode",
+ "isEqualNode",
+ "compareDocumentPosition",
+ "contains",
+ "lookupPrefix",
+ "lookupNamespaceURI",
+ "isDefaultNamespace",
+ ],
+ },
+ Performance: {
+ prototype: ["now"],
+ },
+ Range: {
+ prototype: [
+ "isPointInRange",
+ "comparePoint",
+ "intersectsNode",
+ "getClientRects",
+ "getBoundingClientRect",
+ ],
+ },
+ Selection: {
+ prototype: ["getRangeAt", "containsNode"],
+ },
+ URLSearchParams: {
+ prototype: ["entries", "keys", "values"],
+ },
+};
diff --git a/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js b/devtools/server/actors/webconsole/webidl-unsafe-getters-names.js
new file mode 100644
index 0000000000..9e4faf04c2
--- /dev/null
+++ b/devtools/server/actors/webconsole/webidl-unsafe-getters-names.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 http://mozilla.org/MPL/2.0/. */
+
+// This file is automatically generated by the GenerateDataFromWebIdls.py
+// script. Do not modify it manually.
+"use strict";
+
+module.exports = [
+ "InstallTrigger",
+ "farthestViewportElement",
+ "mozInputSource",
+ "mozPressure",
+ "nearestViewportElement",
+ "onmouseenter",
+ "onmouseleave",
+ "onmozfullscreenchange",
+ "onmozfullscreenerror",
+ "onreadystatechange",
+];
diff --git a/devtools/server/actors/webconsole/worker-listeners.js b/devtools/server/actors/webconsole/worker-listeners.js
new file mode 100644
index 0000000000..6861c6da62
--- /dev/null
+++ b/devtools/server/actors/webconsole/worker-listeners.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+/* global setConsoleEventHandler, retrieveConsoleEvents */
+
+"use strict";
+
+// This file is loaded on the server side for worker debugging.
+// Since the server is running in the worker thread, it doesn't
+// have access to Services / Components but the listeners defined here
+// are imported by webconsole-utils and used for the webconsole actor.
+class ConsoleAPIListener {
+ constructor(window, listener, consoleID) {
+ this.window = window;
+ this.listener = listener;
+ this.consoleID = consoleID;
+ this.observe = this.observe.bind(this);
+ }
+
+ init() {
+ setConsoleEventHandler(this.observe);
+ }
+ destroy() {
+ setConsoleEventHandler(null);
+ }
+ observe(message) {
+ this.listener(message.wrappedJSObject);
+ }
+ getCachedMessages() {
+ return retrieveConsoleEvents();
+ }
+}
+
+exports.ConsoleAPIListener = ConsoleAPIListener;
diff --git a/devtools/server/actors/worker/moz.build b/devtools/server/actors/worker/moz.build
new file mode 100644
index 0000000000..84e606db58
--- /dev/null
+++ b/devtools/server/actors/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/.
+
+DevToolsModules(
+ "push-subscription.js",
+ "service-worker-registration-list.js",
+ "service-worker-registration.js",
+ "service-worker.js",
+ "worker-descriptor-actor-list.js",
+)
diff --git a/devtools/server/actors/worker/push-subscription.js b/devtools/server/actors/worker/push-subscription.js
new file mode 100644
index 0000000000..37e6be7fb4
--- /dev/null
+++ b/devtools/server/actors/worker/push-subscription.js
@@ -0,0 +1,38 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ pushSubscriptionSpec,
+} = require("resource://devtools/shared/specs/worker/push-subscription.js");
+
+class PushSubscriptionActor extends Actor {
+ constructor(conn, subscription) {
+ super(conn, pushSubscriptionSpec);
+ this._subscription = subscription;
+ }
+
+ form() {
+ const subscription = this._subscription;
+
+ // Note: subscription.pushCount & subscription.lastPush are no longer
+ // returned here because the corresponding getters throw on GeckoView.
+ // Since they were not used in DevTools they were removed from the
+ // actor in Bug 1637687. If they are reintroduced, make sure to provide
+ // meaningful fallback values when debugging a GeckoView runtime.
+ return {
+ actor: this.actorID,
+ endpoint: subscription.endpoint,
+ quota: subscription.quota,
+ };
+ }
+
+ destroy() {
+ this._subscription = null;
+ super.destroy();
+ }
+}
+exports.PushSubscriptionActor = PushSubscriptionActor;
diff --git a/devtools/server/actors/worker/service-worker-registration-list.js b/devtools/server/actors/worker/service-worker-registration-list.js
new file mode 100644
index 0000000000..9821108faf
--- /dev/null
+++ b/devtools/server/actors/worker/service-worker-registration-list.js
@@ -0,0 +1,114 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+loader.lazyRequireGetter(
+ this,
+ "ServiceWorkerRegistrationActor",
+ "resource://devtools/server/actors/worker/service-worker-registration.js",
+ true
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "swm",
+ "@mozilla.org/serviceworkers/manager;1",
+ "nsIServiceWorkerManager"
+);
+
+class ServiceWorkerRegistrationActorList {
+ constructor(conn) {
+ this._conn = conn;
+ this._actors = new Map();
+ this._onListChanged = null;
+ this._mustNotify = false;
+ this.onRegister = this.onRegister.bind(this);
+ this.onUnregister = this.onUnregister.bind(this);
+ }
+
+ getList() {
+ // Create a set of registrations.
+ const registrations = new Set();
+ const array = swm.getAllRegistrations();
+ for (let index = 0; index < array.length; ++index) {
+ registrations.add(
+ array.queryElementAt(index, Ci.nsIServiceWorkerRegistrationInfo)
+ );
+ }
+
+ // Delete each actor for which we don't have a registration.
+ for (const [registration] of this._actors) {
+ if (!registrations.has(registration)) {
+ this._actors.delete(registration);
+ }
+ }
+
+ // Create an actor for each registration for which we don't have one.
+ for (const registration of registrations) {
+ if (!this._actors.has(registration)) {
+ this._actors.set(
+ registration,
+ new ServiceWorkerRegistrationActor(this._conn, registration)
+ );
+ }
+ }
+
+ if (!this._mustNotify) {
+ if (this._onListChanged !== null) {
+ swm.addListener(this);
+ }
+ this._mustNotify = true;
+ }
+
+ const actors = [];
+ for (const [, actor] of this._actors) {
+ actors.push(actor);
+ }
+
+ return Promise.resolve(actors);
+ }
+
+ get onListchanged() {
+ return this._onListchanged;
+ }
+
+ set onListChanged(onListChanged) {
+ if (typeof onListChanged !== "function" && onListChanged !== null) {
+ throw new Error("onListChanged must be either a function or null.");
+ }
+
+ if (this._mustNotify) {
+ if (this._onListChanged === null && onListChanged !== null) {
+ swm.addListener(this);
+ }
+ if (this._onListChanged !== null && onListChanged === null) {
+ swm.removeListener(this);
+ }
+ }
+ this._onListChanged = onListChanged;
+ }
+
+ _notifyListChanged() {
+ this._onListChanged();
+
+ if (this._onListChanged !== null) {
+ swm.removeListener(this);
+ }
+ this._mustNotify = false;
+ }
+
+ onRegister(registration) {
+ this._notifyListChanged();
+ }
+
+ onUnregister(registration) {
+ this._notifyListChanged();
+ }
+}
+
+exports.ServiceWorkerRegistrationActorList = ServiceWorkerRegistrationActorList;
diff --git a/devtools/server/actors/worker/service-worker-registration.js b/devtools/server/actors/worker/service-worker-registration.js
new file mode 100644
index 0000000000..1e5e80ae8b
--- /dev/null
+++ b/devtools/server/actors/worker/service-worker-registration.js
@@ -0,0 +1,264 @@
+/* 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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ serviceWorkerRegistrationSpec,
+} = require("resource://devtools/shared/specs/worker/service-worker-registration.js");
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const {
+ PushSubscriptionActor,
+} = require("resource://devtools/server/actors/worker/push-subscription.js");
+const {
+ ServiceWorkerActor,
+} = require("resource://devtools/server/actors/worker/service-worker.js");
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "swm",
+ "@mozilla.org/serviceworkers/manager;1",
+ "nsIServiceWorkerManager"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "PushService",
+ "@mozilla.org/push/Service;1",
+ "nsIPushService"
+);
+
+class ServiceWorkerRegistrationActor extends Actor {
+ /**
+ * Create the ServiceWorkerRegistrationActor
+ * @param DevToolsServerConnection conn
+ * The server connection.
+ * @param ServiceWorkerRegistrationInfo registration
+ * The registration's information.
+ */
+ constructor(conn, registration) {
+ super(conn, serviceWorkerRegistrationSpec);
+ this._registration = registration;
+ this._pushSubscriptionActor = null;
+
+ // A flag to know if preventShutdown has been called and we should
+ // try to allow the shutdown of the SW when the actor is destroyed
+ this._preventedShutdown = false;
+
+ this._registration.addListener(this);
+
+ this._createServiceWorkerActors();
+
+ Services.obs.addObserver(this, PushService.subscriptionModifiedTopic);
+ }
+
+ onChange() {
+ this._destroyServiceWorkerActors();
+ this._createServiceWorkerActors();
+ this.emit("registration-changed");
+ }
+
+ form() {
+ const registration = this._registration;
+ const evaluatingWorker = this._evaluatingWorker.form();
+ const installingWorker = this._installingWorker.form();
+ const waitingWorker = this._waitingWorker.form();
+ const activeWorker = this._activeWorker.form();
+
+ const newestWorker =
+ activeWorker || waitingWorker || installingWorker || evaluatingWorker;
+
+ return {
+ actor: this.actorID,
+ scope: registration.scope,
+ url: registration.scriptSpec,
+ evaluatingWorker,
+ installingWorker,
+ waitingWorker,
+ activeWorker,
+ fetch: newestWorker?.fetch,
+ // Check if we have an active worker
+ active: !!activeWorker,
+ lastUpdateTime: registration.lastUpdateTime,
+ traits: {},
+ };
+ }
+
+ destroy() {
+ super.destroy();
+
+ // Ensure resuming the service worker in case the connection drops
+ if (this._registration.activeWorker && this._preventedShutdown) {
+ this.allowShutdown();
+ }
+
+ Services.obs.removeObserver(this, PushService.subscriptionModifiedTopic);
+ this._registration.removeListener(this);
+ this._registration = null;
+ if (this._pushSubscriptionActor) {
+ this._pushSubscriptionActor.destroy();
+ }
+ this._pushSubscriptionActor = null;
+
+ this._destroyServiceWorkerActors();
+
+ this._evaluatingWorker = null;
+ this._installingWorker = null;
+ this._waitingWorker = null;
+ this._activeWorker = null;
+ }
+
+ /**
+ * Standard observer interface to listen to push messages and changes.
+ */
+ observe(subject, topic, data) {
+ const scope = this._registration.scope;
+ if (data !== scope) {
+ // This event doesn't concern us, pretend nothing happened.
+ return;
+ }
+ switch (topic) {
+ case PushService.subscriptionModifiedTopic:
+ if (this._pushSubscriptionActor) {
+ this._pushSubscriptionActor.destroy();
+ this._pushSubscriptionActor = null;
+ }
+ this.emit("push-subscription-modified");
+ break;
+ }
+ }
+
+ start() {
+ const { activeWorker } = this._registration;
+
+ // TODO: don't return "started" if there's no active worker.
+ if (activeWorker) {
+ // This starts up the Service Worker if it's not already running.
+ // Note that the Service Workers exist in content processes but are
+ // managed from the parent process. This is why we call `attachDebugger`
+ // here (in the parent process) instead of in a process script.
+ activeWorker.attachDebugger();
+ activeWorker.detachDebugger();
+ }
+
+ return { type: "started" };
+ }
+
+ unregister() {
+ const { principal, scope } = this._registration;
+ const unregisterCallback = {
+ unregisterSucceeded() {},
+ unregisterFailed() {
+ console.error("Failed to unregister the service worker for " + scope);
+ },
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIServiceWorkerUnregisterCallback",
+ ]),
+ };
+ swm.propagateUnregister(principal, unregisterCallback, scope);
+
+ return { type: "unregistered" };
+ }
+
+ push() {
+ const { principal, scope } = this._registration;
+ const originAttributes = ChromeUtils.originAttributesToSuffix(
+ principal.originAttributes
+ );
+ swm.sendPushEvent(originAttributes, scope);
+ }
+
+ /**
+ * Prevent the current active worker to shutdown after the idle timeout.
+ */
+ preventShutdown() {
+ if (!this._registration.activeWorker) {
+ throw new Error(
+ "ServiceWorkerRegistrationActor.preventShutdown could not find " +
+ "activeWorker in parent-intercept mode"
+ );
+ }
+
+ // attachDebugger has to be called from the parent process in parent-intercept mode.
+ this._registration.activeWorker.attachDebugger();
+ this._preventedShutdown = true;
+ }
+
+ /**
+ * Allow the current active worker to shut down again.
+ */
+ allowShutdown() {
+ if (!this._registration.activeWorker) {
+ throw new Error(
+ "ServiceWorkerRegistrationActor.allowShutdown could not find " +
+ "activeWorker in parent-intercept mode"
+ );
+ }
+
+ this._registration.activeWorker.detachDebugger();
+ this._preventedShutdown = false;
+ }
+
+ getPushSubscription() {
+ const registration = this._registration;
+ let pushSubscriptionActor = this._pushSubscriptionActor;
+ if (pushSubscriptionActor) {
+ return Promise.resolve(pushSubscriptionActor);
+ }
+ return new Promise((resolve, reject) => {
+ PushService.getSubscription(
+ registration.scope,
+ registration.principal,
+ (result, subscription) => {
+ if (!subscription) {
+ resolve(null);
+ return;
+ }
+ pushSubscriptionActor = new PushSubscriptionActor(
+ this.conn,
+ subscription
+ );
+ this._pushSubscriptionActor = pushSubscriptionActor;
+ resolve(pushSubscriptionActor);
+ }
+ );
+ });
+ }
+
+ _destroyServiceWorkerActors() {
+ this._evaluatingWorker.destroy();
+ this._installingWorker.destroy();
+ this._waitingWorker.destroy();
+ this._activeWorker.destroy();
+ }
+
+ _createServiceWorkerActors() {
+ const { evaluatingWorker, installingWorker, waitingWorker, activeWorker } =
+ this._registration;
+
+ this._evaluatingWorker = new ServiceWorkerActor(
+ this.conn,
+ evaluatingWorker
+ );
+ this._installingWorker = new ServiceWorkerActor(
+ this.conn,
+ installingWorker
+ );
+ this._waitingWorker = new ServiceWorkerActor(this.conn, waitingWorker);
+ this._activeWorker = new ServiceWorkerActor(this.conn, activeWorker);
+
+ // Add the ServiceWorker actors as children of this ServiceWorkerRegistration actor,
+ // assigning them valid actorIDs.
+ this.manage(this._evaluatingWorker);
+ this.manage(this._installingWorker);
+ this.manage(this._waitingWorker);
+ this.manage(this._activeWorker);
+ }
+}
+
+exports.ServiceWorkerRegistrationActor = ServiceWorkerRegistrationActor;
diff --git a/devtools/server/actors/worker/service-worker.js b/devtools/server/actors/worker/service-worker.js
new file mode 100644
index 0000000000..9185e73e17
--- /dev/null
+++ b/devtools/server/actors/worker/service-worker.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 { Actor } = require("resource://devtools/shared/protocol.js");
+const {
+ serviceWorkerSpec,
+} = require("resource://devtools/shared/specs/worker/service-worker.js");
+
+class ServiceWorkerActor extends Actor {
+ constructor(conn, worker) {
+ super(conn, serviceWorkerSpec);
+ this._worker = worker;
+ }
+
+ form() {
+ if (!this._worker) {
+ return null;
+ }
+
+ // handlesFetchEvents is not available if the worker's main script is in the
+ // evaluating state.
+ const isEvaluating =
+ this._worker.state == Ci.nsIServiceWorkerInfo.STATE_PARSED;
+ const fetch = isEvaluating ? undefined : this._worker.handlesFetchEvents;
+
+ return {
+ actor: this.actorID,
+ url: this._worker.scriptSpec,
+ state: this._worker.state,
+ fetch,
+ id: this._worker.id,
+ };
+ }
+
+ destroy() {
+ super.destroy();
+ this._worker = null;
+ }
+}
+
+exports.ServiceWorkerActor = ServiceWorkerActor;
diff --git a/devtools/server/actors/worker/worker-descriptor-actor-list.js b/devtools/server/actors/worker/worker-descriptor-actor-list.js
new file mode 100644
index 0000000000..10bdb5d5d3
--- /dev/null
+++ b/devtools/server/actors/worker/worker-descriptor-actor-list.js
@@ -0,0 +1,213 @@
+/* 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 { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+loader.lazyRequireGetter(
+ this,
+ "WorkerDescriptorActor",
+ "resource://devtools/server/actors/descriptors/worker.js",
+ true
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "wdm",
+ "@mozilla.org/dom/workers/workerdebuggermanager;1",
+ "nsIWorkerDebuggerManager"
+);
+
+function matchWorkerDebugger(dbg, options) {
+ if ("type" in options && dbg.type !== options.type) {
+ return false;
+ }
+ if ("window" in options) {
+ let window = dbg.window;
+ while (window !== null && window.parent !== window) {
+ window = window.parent;
+ }
+
+ if (window !== options.window) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function matchServiceWorker(dbg, origin) {
+ return (
+ dbg.type == Ci.nsIWorkerDebugger.TYPE_SERVICE &&
+ new URL(dbg.url).origin == origin
+ );
+}
+
+// When a new worker appears, in some cases (i.e. the debugger is running) we
+// want it to pause during registration until a later time (i.e. the debugger
+// finishes attaching to the worker). This is an optional WorkderDebuggerManager
+// listener that can be installed in addition to the WorkerDescriptorActorList
+// listener. It always listens to new workers and pauses any matching filters
+// which have been set on it.
+//
+// Two kinds of filters are supported:
+//
+// setPauseMatching(true) will pause all workers which match the options strcut
+// passed in on creation.
+//
+// setPauseServiceWorkers(origin) will pause all service workers which have the
+// specified origin.
+//
+// FIXME Bug 1601279 separate WorkerPauser from WorkerDescriptorActorList and give
+// it a more consistent interface.
+class WorkerPauser {
+ constructor(options) {
+ this._options = options;
+ this._pauseMatching = null;
+ this._pauseServiceWorkerOrigin = null;
+
+ this.onRegister = this._onRegister.bind(this);
+ this.onUnregister = () => {};
+
+ wdm.addListener(this);
+ }
+
+ destroy() {
+ wdm.removeListener(this);
+ }
+
+ _onRegister(dbg) {
+ if (
+ (this._pauseMatching && matchWorkerDebugger(dbg, this._options)) ||
+ (this._pauseServiceWorkerOrigin &&
+ matchServiceWorker(dbg, this._pauseServiceWorkerOrigin))
+ ) {
+ // Prevent the debuggee from executing in this worker until the debugger
+ // has finished attaching to it.
+ dbg.setDebuggerReady(false);
+ }
+ }
+
+ setPauseMatching(shouldPause) {
+ this._pauseMatching = shouldPause;
+ }
+
+ setPauseServiceWorkers(origin) {
+ this._pauseServiceWorkerOrigin = origin;
+ }
+}
+
+class WorkerDescriptorActorList {
+ constructor(conn, options) {
+ this._conn = conn;
+ this._options = options;
+ this._actors = new Map();
+ this._onListChanged = null;
+ this._workerPauser = null;
+ this._mustNotify = false;
+ this.onRegister = this.onRegister.bind(this);
+ this.onUnregister = this.onUnregister.bind(this);
+ }
+
+ destroy() {
+ this.onListChanged = null;
+ if (this._workerPauser) {
+ this._workerPauser.destroy();
+ this._workerPauser = null;
+ }
+ }
+
+ getList() {
+ // Create a set of debuggers.
+ const dbgs = new Set();
+ for (const dbg of wdm.getWorkerDebuggerEnumerator()) {
+ if (matchWorkerDebugger(dbg, this._options)) {
+ dbgs.add(dbg);
+ }
+ }
+
+ // Delete each actor for which we don't have a debugger.
+ for (const [dbg] of this._actors) {
+ if (!dbgs.has(dbg)) {
+ this._actors.delete(dbg);
+ }
+ }
+
+ // Create an actor for each debugger for which we don't have one.
+ for (const dbg of dbgs) {
+ if (!this._actors.has(dbg)) {
+ this._actors.set(dbg, new WorkerDescriptorActor(this._conn, dbg));
+ }
+ }
+
+ const actors = [];
+ for (const [, actor] of this._actors) {
+ actors.push(actor);
+ }
+
+ if (!this._mustNotify) {
+ if (this._onListChanged !== null) {
+ wdm.addListener(this);
+ }
+ this._mustNotify = true;
+ }
+
+ return Promise.resolve(actors);
+ }
+
+ get onListChanged() {
+ return this._onListChanged;
+ }
+
+ set onListChanged(onListChanged) {
+ if (typeof onListChanged !== "function" && onListChanged !== null) {
+ throw new Error("onListChanged must be either a function or null.");
+ }
+ if (onListChanged === this._onListChanged) {
+ return;
+ }
+
+ if (this._mustNotify) {
+ if (this._onListChanged === null && onListChanged !== null) {
+ wdm.addListener(this);
+ }
+ if (this._onListChanged !== null && onListChanged === null) {
+ wdm.removeListener(this);
+ }
+ }
+ this._onListChanged = onListChanged;
+ }
+
+ _notifyListChanged() {
+ this._onListChanged();
+
+ if (this._onListChanged !== null) {
+ wdm.removeListener(this);
+ }
+ this._mustNotify = false;
+ }
+
+ onRegister(dbg) {
+ if (matchWorkerDebugger(dbg, this._options)) {
+ this._notifyListChanged();
+ }
+ }
+
+ onUnregister(dbg) {
+ if (matchWorkerDebugger(dbg, this._options)) {
+ this._notifyListChanged();
+ }
+ }
+
+ get workerPauser() {
+ if (!this._workerPauser) {
+ this._workerPauser = new WorkerPauser(this._options);
+ }
+ return this._workerPauser;
+ }
+}
+
+exports.WorkerDescriptorActorList = WorkerDescriptorActorList;
diff --git a/devtools/server/connectors/content-process-connector.js b/devtools/server/connectors/content-process-connector.js
new file mode 100644
index 0000000000..ea95a5d6ab
--- /dev/null
+++ b/devtools/server/connectors/content-process-connector.js
@@ -0,0 +1,125 @@
+/* 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";
+
+var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+var { dumpn } = DevToolsUtils;
+var {
+ createContentProcessSessionContext,
+} = require("resource://devtools/server/actors/watcher/session-context.js");
+
+loader.lazyRequireGetter(
+ this,
+ "ChildDebuggerTransport",
+ "resource://devtools/shared/transport/child-transport.js",
+ true
+);
+
+const CONTENT_PROCESS_SERVER_STARTUP_SCRIPT =
+ "resource://devtools/server/startup/content-process.js";
+
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+
+/**
+ * Start a DevTools server in a content process (representing the entire process, not
+ * just a single frame) and add it as a child server for an active connection.
+ */
+function connectToContentProcess(connection, mm, onDestroy) {
+ return new Promise(resolve => {
+ const prefix = connection.allocID("content-process");
+ let actor, childTransport;
+
+ mm.addMessageListener(
+ "debug:content-process-actor",
+ function listener(msg) {
+ // Ignore actors being created by a Watcher actor,
+ // they will be handled by devtools/server/watcher/target-helpers/process.js
+ if (msg.watcherActorID) {
+ return;
+ }
+ mm.removeMessageListener("debug:content-process-actor", listener);
+
+ // Pipe Debugger message from/to parent/child via the message manager
+ childTransport = new ChildDebuggerTransport(mm, prefix);
+ childTransport.hooks = {
+ onPacket: connection.send.bind(connection),
+ };
+ childTransport.ready();
+
+ connection.setForwarding(prefix, childTransport);
+
+ dumpn(`Start forwarding for process with prefix ${prefix}`);
+
+ actor = msg.json.actor;
+
+ resolve(actor);
+ }
+ );
+
+ // Load the content process server startup script only once.
+ const isContentProcessServerStartupScripLoaded = Services.ppmm
+ .getDelayedProcessScripts()
+ .some(([uri]) => uri === CONTENT_PROCESS_SERVER_STARTUP_SCRIPT);
+ if (!isContentProcessServerStartupScripLoaded) {
+ // Load the process script that will receive the debug:init-content-server message
+ Services.ppmm.loadProcessScript(
+ CONTENT_PROCESS_SERVER_STARTUP_SCRIPT,
+ true
+ );
+ }
+
+ // Send a message to the content process server startup script to forward it the
+ // prefix.
+ mm.sendAsyncMessage("debug:init-content-server", {
+ prefix,
+ // This connector is only used for the Browser Content Toolbox,
+ // when creating the content process target from the Process Descriptor.
+ sessionContext: createContentProcessSessionContext(),
+ });
+
+ function onClose() {
+ Services.obs.removeObserver(
+ onMessageManagerClose,
+ "message-manager-close"
+ );
+ EventEmitter.off(connection, "closed", onClose);
+ if (childTransport) {
+ // If we have a child transport, the actor has already
+ // been created. We need to stop using this message manager.
+ childTransport.close();
+ childTransport = null;
+ connection.cancelForwarding(prefix);
+
+ // ... and notify the child process to clean the target-scoped actors.
+ try {
+ mm.sendAsyncMessage("debug:content-process-disconnect", { prefix });
+ } catch (e) {
+ // Nothing to do
+ }
+ }
+
+ if (onDestroy) {
+ onDestroy(mm);
+ }
+ }
+
+ const onMessageManagerClose = DevToolsUtils.makeInfallible(
+ (subject, topic, data) => {
+ if (subject == mm) {
+ onClose();
+ }
+ }
+ );
+ Services.obs.addObserver(onMessageManagerClose, "message-manager-close");
+
+ EventEmitter.on(connection, "closed", onClose);
+ });
+}
+
+exports.connectToContentProcess = connectToContentProcess;
diff --git a/devtools/server/connectors/frame-connector.js b/devtools/server/connectors/frame-connector.js
new file mode 100644
index 0000000000..789d405d90
--- /dev/null
+++ b/devtools/server/connectors/frame-connector.js
@@ -0,0 +1,171 @@
+/* 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";
+
+var DevToolsUtils = require("devtools/shared/DevToolsUtils");
+var { dumpn } = DevToolsUtils;
+
+loader.lazyRequireGetter(
+ this,
+ "DevToolsServer",
+ "resource://devtools/server/devtools-server.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ChildDebuggerTransport",
+ "resource://devtools/shared/transport/child-transport.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+
+/**
+ * Start a DevTools server in a remote frame's process and add it as a child server for
+ * an active connection.
+ *
+ * @param object connection
+ * The devtools server connection to use.
+ * @param Element frame
+ * The frame element with remote content to connect to.
+ * @param function [onDestroy]
+ * Optional function to invoke when the child process closes or the connection
+ * shuts down. (Need to forget about the related target actor.)
+ * @return object
+ * A promise object that is resolved once the connection is established.
+ */
+function connectToFrame(
+ connection,
+ frame,
+ onDestroy,
+ { addonId, addonBrowsingContextGroupId } = {}
+) {
+ return new Promise(resolve => {
+ // Get messageManager from XUL browser (which might be a specialized tunnel for RDM)
+ // or else fallback to asking the frameLoader itself.
+ const mm = frame.messageManager || frame.frameLoader.messageManager;
+ mm.loadFrameScript("resource://devtools/server/startup/frame.js", false);
+
+ const trackMessageManager = () => {
+ if (!actor) {
+ mm.addMessageListener("debug:actor", onActorCreated);
+ }
+ };
+
+ const untrackMessageManager = () => {
+ if (!actor) {
+ mm.removeMessageListener("debug:actor", onActorCreated);
+ }
+ };
+
+ let actor, childTransport;
+ const prefix = connection.allocID("child");
+ // Compute the same prefix that's used by DevToolsServerConnection
+ const connPrefix = prefix + "/";
+
+ const onActorCreated = DevToolsUtils.makeInfallible(function (msg) {
+ if (msg.json.prefix != prefix) {
+ return;
+ }
+ mm.removeMessageListener("debug:actor", onActorCreated);
+
+ // Pipe Debugger message from/to parent/child via the message manager
+ childTransport = new ChildDebuggerTransport(mm, prefix);
+ childTransport.hooks = {
+ // Pipe all the messages from content process actors back to the client
+ // through the parent process connection.
+ onPacket: connection.send.bind(connection),
+ };
+ childTransport.ready();
+
+ connection.setForwarding(prefix, childTransport);
+
+ dumpn(`Start forwarding for frame with prefix ${prefix}`);
+
+ actor = msg.json.actor;
+ resolve(actor);
+ });
+
+ const destroy = DevToolsUtils.makeInfallible(function () {
+ EventEmitter.off(connection, "closed", destroy);
+ Services.obs.removeObserver(
+ onMessageManagerClose,
+ "message-manager-close"
+ );
+
+ // TODO: Remove this deprecated path once it's no longer needed by add-ons.
+ DevToolsServer.emit("disconnected-from-child:" + connPrefix, {
+ mm,
+ prefix: connPrefix,
+ });
+
+ if (actor) {
+ actor = null;
+ }
+
+ // Notify the tab descriptor about the destruction before the call to
+ // `cancelForwarding`, so that we notify about the target destruction
+ // *before* we purge all request for this prefix.
+ // When we purge the requests, we also destroy all related fronts,
+ // including the target front. This clears all event listeners
+ // and ultimately prevent target-destroyed from firing.
+ if (onDestroy) {
+ onDestroy(mm);
+ }
+
+ if (childTransport) {
+ // If we have a child transport, the actor has already
+ // been created. We need to stop using this message manager.
+ childTransport.close();
+ childTransport = null;
+ connection.cancelForwarding(prefix);
+
+ // ... and notify the child process to clean the target-scoped actors.
+ try {
+ // Bug 1169643: Ignore any exception as the child process
+ // may already be destroyed by now.
+ mm.sendAsyncMessage("debug:disconnect", { prefix });
+ } catch (e) {
+ // Nothing to do
+ }
+ } else {
+ // Otherwise, the frame has been closed before the actor
+ // had a chance to be created, so we are not able to create
+ // the actor.
+ resolve(null);
+ }
+
+ // Cleanup all listeners
+ untrackMessageManager();
+ });
+
+ // Listen for various messages and frame events
+ trackMessageManager();
+
+ // Listen for app process exit
+ const onMessageManagerClose = function (subject, topic, data) {
+ if (subject == mm) {
+ destroy();
+ }
+ };
+ Services.obs.addObserver(onMessageManagerClose, "message-manager-close");
+
+ // Listen for connection close to cleanup things
+ // when user unplug the device or we lose the connection somehow.
+ EventEmitter.on(connection, "closed", destroy);
+
+ mm.sendAsyncMessage("debug:connect", {
+ prefix,
+ addonId,
+ addonBrowsingContextGroupId,
+ });
+ });
+}
+
+exports.connectToFrame = connectToFrame;
diff --git a/devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs
new file mode 100644
index 0000000000..519cd10325
--- /dev/null
+++ b/devtools/server/connectors/js-window-actor/DevToolsFrameChild.sys.mjs
@@ -0,0 +1,706 @@
+/* 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/. */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+import * as Loader from "resource://devtools/shared/loader/Loader.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ isWindowGlobalPartOfContext:
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
+ releaseDistinctSystemPrincipalLoader:
+ "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs",
+ TargetActorRegistry:
+ "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs",
+ useDistinctSystemPrincipalLoader:
+ "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs",
+ WindowGlobalLogger:
+ "resource://devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs",
+});
+
+const isEveryFrameTargetEnabled = Services.prefs.getBoolPref(
+ "devtools.every-frame-target.enabled",
+ false
+);
+
+// Name of the attribute into which we save data in `sharedData` object.
+const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher";
+
+// If true, log info about WindowGlobal's being created.
+const DEBUG = false;
+function logWindowGlobal(windowGlobal, message) {
+ if (!DEBUG) {
+ return;
+ }
+ lazy.WindowGlobalLogger.logWindowGlobal(windowGlobal, message);
+}
+
+export class DevToolsFrameChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ // The map is indexed by the Watcher Actor ID.
+ // The values are objects containing the following properties:
+ // - connection: the DevToolsServerConnection itself
+ // - actor: the WindowGlobalTargetActor instance
+ this._connections = new Map();
+
+ EventEmitter.decorate(this);
+
+ // Set the following preference on the constructor, so that we can easily
+ // toggle these preferences on and off from tests and have the new value being picked up.
+
+ // bfcache-in-parent changes significantly how navigation behaves.
+ // We may start reusing previously existing WindowGlobal and so reuse
+ // previous set of JSWindowActor pairs (i.e. DevToolsFrameParent/DevToolsFrameChild).
+ // When enabled, regular navigations may also change and spawn new BrowsingContexts.
+ // If the page we navigate from supports being stored in bfcache,
+ // the navigation will use a new BrowsingContext. And so force spawning
+ // a new top-level target.
+ ChromeUtils.defineLazyGetter(
+ this,
+ "isBfcacheInParentEnabled",
+ () =>
+ Services.appinfo.sessionHistoryInParent &&
+ Services.prefs.getBoolPref("fission.bfcacheInParent", false)
+ );
+ }
+
+ /**
+ * Try to instantiate new target actors for the current WindowGlobal, and that,
+ * for all the currently registered Watcher actors.
+ *
+ * Read the `sharedData` to get metadata about all registered watcher actors.
+ * If these watcher actors are interested in the current WindowGlobal,
+ * instantiate a new dedicated target actor for each of the watchers.
+ *
+ * @param Object options
+ * @param Boolean options.isBFCache
+ * True, if the request to instantiate a new target comes from a bfcache navigation.
+ * i.e. when we receive a pageshow event with persisted=true.
+ * This will be true regardless of bfcacheInParent being enabled or disabled.
+ * @param Boolean options.ignoreIfExisting
+ * By default to false. If true is passed, we avoid instantiating a target actor
+ * if one already exists for this windowGlobal.
+ */
+ instantiate({ isBFCache = false, ignoreIfExisting = false } = {}) {
+ const { sharedData } = Services.cpmm;
+ const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
+ if (!sessionDataByWatcherActor) {
+ throw new Error(
+ "Request to instantiate the target(s) for the BrowsingContext, but `sharedData` is empty about watched targets"
+ );
+ }
+
+ // Create one Target actor for each prefix/client which listen to frames
+ for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) {
+ const { connectionPrefix, sessionContext } = sessionData;
+ // For bfcache navigations, we only create new targets when bfcacheInParent is enabled,
+ // as this would be the only case where new DocShells will be created. This requires us to spawn a
+ // new WindowGlobalTargetActor as one such actor is bound to a unique DocShell.
+ const forceAcceptTopLevelTarget =
+ isBFCache && this.isBfcacheInParentEnabled;
+ if (
+ sessionData.targets?.includes("frame") &&
+ lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, {
+ forceAcceptTopLevelTarget,
+ })
+ ) {
+ // If this was triggered because of a navigation, we want to retrieve the existing
+ // target we were debugging so we can destroy it before creating the new target.
+ // This is important because we had cases where the destruction of an old target
+ // was unsetting a flag on the **new** target document, breaking the toolbox (See Bug 1721398).
+
+ // We're checking for an existing target given a watcherActorID + browserId + browsingContext
+ // Note that a target switch might create a new browsing context, so we wouldn't
+ // retrieve the existing target here. We are okay with this as:
+ // - this shouldn't happen much
+ // - in such case we weren't seeing the issue of Bug 1721398 (the old target can't access the new document)
+ const existingTarget = this._findTargetActor({
+ watcherActorID,
+ sessionContext,
+ browsingContextId: this.manager.browsingContext.id,
+ });
+
+ // See comment in handleEvent(DOMDocElementInserted) to know why we try to
+ // create targets if none already exists
+ if (existingTarget && ignoreIfExisting) {
+ continue;
+ }
+
+ // Bail if there is already an existing WindowGlobalTargetActor which wasn't
+ // created from a JSWIndowActor.
+ // This means we are reloading or navigating (same-process) a Target
+ // which has not been created using the Watcher, but from the client (most likely
+ // the initial target of a local-tab toolbox).
+ // However, we force overriding the first message manager based target in case of
+ // BFCache navigations.
+ if (
+ existingTarget &&
+ !existingTarget.createdFromJsWindowActor &&
+ !isBFCache
+ ) {
+ continue;
+ }
+
+ // If we decide to instantiate a new target and there was one before,
+ // first destroy the previous one.
+ // Otherwise its destroy sequence will be executed *after* the new one
+ // is being initialized and may easily revert changes made against platform API.
+ // (typically toggle platform boolean attributes back to default…)
+ if (existingTarget) {
+ existingTarget.destroy({ isTargetSwitching: true });
+ }
+
+ this._createTargetActor({
+ watcherActorID,
+ parentConnectionPrefix: connectionPrefix,
+ sessionData,
+ isDocumentCreation: true,
+ });
+ }
+ }
+ }
+
+ /**
+ * Instantiate a new WindowGlobalTarget for the given connection.
+ *
+ * @param Object options
+ * @param String options.watcherActorID
+ * The ID of the WatcherActor who requested to observe and create these target actors.
+ * @param String options.parentConnectionPrefix
+ * The prefix of the DevToolsServerConnection of the Watcher Actor.
+ * This is used to compute a unique ID for the target actor.
+ * @param Object options.sessionData
+ * All data managed by the Watcher Actor and WatcherRegistry.jsm, containing
+ * target types, resources types to be listened as well as breakpoints and any
+ * other data meant to be shared across processes and threads.
+ * @param Boolean options.isDocumentCreation
+ * Set to true if the function is called from `instantiate`, i.e. when we're
+ * handling a new document being created.
+ * @param Boolean options.fromInstantiateAlreadyAvailable
+ * Set to true if the function is called from handling `DevToolsFrameParent:instantiate-already-available`
+ * query.
+ */
+ _createTargetActor({
+ watcherActorID,
+ parentConnectionPrefix,
+ sessionData,
+ isDocumentCreation,
+ fromInstantiateAlreadyAvailable,
+ }) {
+ if (this._connections.get(watcherActorID)) {
+ // If this function is called as a result of a `DevToolsFrameParent:instantiate-already-available`
+ // message, we might have a legitimate race condition:
+ // In frame-helper, we want to create the initial targets for a given browser element.
+ // It might happen that the `DevToolsFrameParent:instantiate-already-available` is
+ // aborted if the page navigates (and the document is destroyed) while the query is still pending.
+ // In such case, frame-helper will try to send a new message. In the meantime,
+ // the DevToolsFrameChild `DOMWindowCreated` handler may already have been called and
+ // the new target already created.
+ // We don't want to throw in such case, as our end-goal, having a target for the document,
+ // is achieved.
+ if (fromInstantiateAlreadyAvailable) {
+ return;
+ }
+ throw new Error(
+ "DevToolsFrameChild _createTargetActor was called more than once" +
+ ` for the same Watcher (Actor ID: "${watcherActorID}")`
+ );
+ }
+
+ // Compute a unique prefix, just for this WindowGlobal,
+ // which will be used to create a JSWindowActorTransport pair between content and parent processes.
+ // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`,
+ // but here, we can't have access to any DevTools connection as we are really early in the content process startup
+ // XXX: WindowGlobal's innerWindowId should be unique across processes, I think. So that should be safe?
+ // (this.manager == WindowGlobalChild interface)
+ const forwardingPrefix =
+ parentConnectionPrefix + "windowGlobal" + this.manager.innerWindowId;
+
+ logWindowGlobal(
+ this.manager,
+ "Instantiate WindowGlobalTarget with prefix: " + forwardingPrefix
+ );
+
+ const { connection, targetActor } = this._createConnectionAndActor(
+ forwardingPrefix,
+ sessionData
+ );
+ const form = targetActor.form();
+ // Ensure unregistering and destroying the related DevToolsServerConnection+Transport
+ // on both content and parent process JSWindowActors.
+ targetActor.once("destroyed", options => {
+ // This will destroy the content process one
+ this._destroyTargetActor(watcherActorID, options);
+ // And this will destroy the parent process one
+ try {
+ this.sendAsyncMessage("DevToolsFrameChild:destroy", {
+ actors: [
+ {
+ watcherActorID,
+ form,
+ },
+ ],
+ options,
+ });
+ } catch (e) {
+ // Ignore exception when the JSWindowActorChild has already been destroyed.
+ // We often try to emit this message while the WindowGlobal is in the process of being
+ // destroyed. We eagerly destroy the target actor during reloads,
+ // just before the WindowGlobal starts destroying, but sendAsyncMessage
+ // doesn't have time to complete and throws.
+ if (
+ !e.message.includes("JSWindowActorChild cannot send at the moment")
+ ) {
+ throw e;
+ }
+ }
+ });
+ this._connections.set(watcherActorID, {
+ connection,
+ actor: targetActor,
+ });
+
+ // Immediately queue a message for the parent process,
+ // in order to ensure that the JSWindowActorTransport is instantiated
+ // before any packet is sent from the content process.
+ // As the order of messages is guaranteed to be delivered in the order they
+ // were queued, we don't have to wait for anything around this sendAsyncMessage call.
+ // In theory, the WindowGlobalTargetActor may emit events in its constructor.
+ // If it does, such RDP packets may be lost.
+ // The important point here is to send this message before processing the sessionData,
+ // which will start the Watcher and start emitting resources on the target actor.
+ this.sendAsyncMessage("DevToolsFrameChild:connectFromContent", {
+ watcherActorID,
+ forwardingPrefix,
+ actor: targetActor.form(),
+ });
+
+ // Pass initialization data to the target actor
+ for (const type in sessionData) {
+ // `sessionData` will also contain `browserId` as well as entries with empty arrays,
+ // which shouldn't be processed.
+ const entries = sessionData[type];
+ if (!Array.isArray(entries) || !entries.length) {
+ continue;
+ }
+ targetActor.addOrSetSessionDataEntry(
+ type,
+ entries,
+ isDocumentCreation,
+ "set"
+ );
+ }
+ }
+
+ /**
+ * @param {string} watcherActorID
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+ _destroyTargetActor(watcherActorID, options) {
+ const connectionInfo = this._connections.get(watcherActorID);
+ // This connection has already been cleaned?
+ if (!connectionInfo) {
+ throw new Error(
+ `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}`
+ );
+ }
+ connectionInfo.connection.close(options);
+ this._connections.delete(watcherActorID);
+ if (this._connections.size == 0) {
+ this.didDestroy(options);
+ }
+ }
+
+ _createConnectionAndActor(forwardingPrefix, sessionData) {
+ this.useCustomLoader = this.document.nodePrincipal.isSystemPrincipal;
+
+ if (!this.loader) {
+ // When debugging chrome pages, use a new dedicated loader, using a distinct chrome compartment.
+ this.loader = this.useCustomLoader
+ ? lazy.useDistinctSystemPrincipalLoader(this)
+ : Loader;
+ }
+ const { DevToolsServer } = this.loader.require(
+ "resource://devtools/server/devtools-server.js"
+ );
+
+ const { WindowGlobalTargetActor } = this.loader.require(
+ "resource://devtools/server/actors/targets/window-global.js"
+ );
+
+ DevToolsServer.init();
+
+ // We want a special server without any root actor and only target-scoped actors.
+ // We are going to spawn a WindowGlobalTargetActor instance in the next few lines,
+ // it is going to act like a root actor without being one.
+ DevToolsServer.registerActors({ target: true });
+
+ const connection = DevToolsServer.connectToParentWindowActor(
+ this,
+ forwardingPrefix
+ );
+
+ // In the case of the browser toolbox, tab's BrowsingContext don't have
+ // any parent BC and shouldn't be considered as top-level.
+ // This is why we check for browserId's.
+ const browsingContext = this.manager.browsingContext;
+ const isTopLevelTarget =
+ !browsingContext.parent &&
+ browsingContext.browserId == sessionData.sessionContext.browserId;
+
+ // Create the actual target actor.
+ const targetActor = new WindowGlobalTargetActor(connection, {
+ docShell: this.docShell,
+ // Targets created from the server side, via Watcher actor and DevToolsFrame JSWindow
+ // actor pair are following WindowGlobal lifecycle. i.e. will be destroyed on any
+ // type of navigation/reload.
+ followWindowGlobalLifeCycle: true,
+ isTopLevelTarget,
+ ignoreSubFrames: isEveryFrameTargetEnabled,
+ sessionContext: sessionData.sessionContext,
+ });
+ targetActor.manage(targetActor);
+ targetActor.createdFromJsWindowActor = true;
+
+ return { connection, targetActor };
+ }
+
+ /**
+ * Supported Queries
+ */
+
+ sendPacket(packet, prefix) {
+ this.sendAsyncMessage("DevToolsFrameChild:packet", { packet, prefix });
+ }
+
+ /**
+ * JsWindowActor API
+ */
+
+ async sendQuery(msg, args) {
+ try {
+ const res = await super.sendQuery(msg, args);
+ return res;
+ } catch (e) {
+ console.error("Failed to sendQuery in DevToolsFrameChild", msg);
+ console.error(e.toString());
+ throw e;
+ }
+ }
+
+ receiveMessage(message) {
+ // Assert that the message is intended for this window global,
+ // except for "packet" messages which use a dedicated routing
+ if (
+ message.name != "DevToolsFrameParent:packet" &&
+ message.data.sessionContext.type == "browser-element"
+ ) {
+ const { browserId } = message.data.sessionContext;
+ // Re-check here, just to ensure that both parent and content processes agree
+ // on what should or should not be watched.
+ if (
+ this.manager.browsingContext.browserId != browserId &&
+ !lazy.isWindowGlobalPartOfContext(
+ this.manager,
+ message.data.sessionContext,
+ {
+ forceAcceptTopLevelTarget: true,
+ }
+ )
+ ) {
+ throw new Error(
+ "Mismatch between DevToolsFrameParent and DevToolsFrameChild " +
+ (this.manager.browsingContext.browserId == browserId
+ ? "window global shouldn't be notified (isWindowGlobalPartOfContext mismatch)"
+ : `expected browsing context with browserId ${browserId}, but got ${this.manager.browsingContext.browserId}`)
+ );
+ }
+ }
+ switch (message.name) {
+ case "DevToolsFrameParent:instantiate-already-available": {
+ const { watcherActorID, connectionPrefix, sessionData } = message.data;
+
+ return this._createTargetActor({
+ watcherActorID,
+ parentConnectionPrefix: connectionPrefix,
+ sessionData,
+ fromInstantiateAlreadyAvailable: true,
+ });
+ }
+ case "DevToolsFrameParent:destroy": {
+ const { watcherActorID, options } = message.data;
+ return this._destroyTargetActor(watcherActorID, options);
+ }
+ case "DevToolsFrameParent:addOrSetSessionDataEntry": {
+ const { watcherActorID, sessionContext, type, entries, updateType } =
+ message.data;
+ return this._addOrSetSessionDataEntry(
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType
+ );
+ }
+ case "DevToolsFrameParent:removeSessionDataEntry": {
+ const { watcherActorID, sessionContext, type, entries } = message.data;
+ return this._removeSessionDataEntry(
+ watcherActorID,
+ sessionContext,
+ type,
+ entries
+ );
+ }
+ case "DevToolsFrameParent:packet":
+ return this.emit("packet-received", message);
+ default:
+ throw new Error(
+ "Unsupported message in DevToolsFrameParent: " + message.name
+ );
+ }
+ }
+
+ /**
+ * Return an existing target given a WatcherActor id, a browserId and an optional
+ * browsing context id.
+ * /!\ Note that we may have multiple targets for a given (watcherActorId, browserId) couple,
+ * for example if we have 2 remote iframes sharing the same origin, which is why you
+ * might want to pass a specific browsing context id to filter the list down.
+ *
+ * @param {Object} options
+ * @param {Object} options.watcherActorID
+ * @param {Object} options.sessionContext
+ * @param {Object} options.browsingContextId: Optional browsing context id to narrow the
+ * search to a specific browsing context.
+ *
+ * @returns {WindowGlobalTargetActor|null}
+ */
+ _findTargetActor({ watcherActorID, sessionContext, browsingContextId }) {
+ // First let's check if a target was created for this watcher actor in this specific
+ // DevToolsFrameChild instance.
+ const connectionInfo = this._connections.get(watcherActorID);
+ const targetActor = connectionInfo ? connectionInfo.actor : null;
+ if (targetActor) {
+ return targetActor;
+ }
+
+ // If we couldn't find such target, we want to see if a target was created for this
+ // (watcherActorId,browserId, {browsingContextId}) in another DevToolsFrameChild instance.
+ // This might be the case if we're navigating to a new page with server side target
+ // enabled and we want to retrieve the target of the page we're navigating from.
+ if (
+ lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, {
+ forceAcceptTopLevelTarget: true,
+ })
+ ) {
+ // Ensure retrieving the one target actor related to this connection.
+ // This allows to distinguish actors created for various toolboxes.
+ // For ex, regular toolbox versus browser console versus browser toolbox
+ const connectionPrefix = watcherActorID.replace(/watcher\d+$/, "");
+ const targetActors = lazy.TargetActorRegistry.getTargetActors(
+ sessionContext,
+ connectionPrefix
+ );
+
+ if (!browsingContextId) {
+ return targetActors[0] || null;
+ }
+ return targetActors.find(
+ actor => actor.browsingContextID === browsingContextId
+ );
+ }
+ return null;
+ }
+
+ _addOrSetSessionDataEntry(
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType
+ ) {
+ // /!\ We may have an issue here as there could be multiple targets for a given
+ // (watcherActorID,browserId) pair.
+ // This should be clarified as part of Bug 1725623.
+ const targetActor = this._findTargetActor({
+ watcherActorID,
+ sessionContext,
+ });
+
+ if (!targetActor) {
+ throw new Error(
+ `No target actor for this Watcher Actor ID:"${watcherActorID}" / BrowserId:${sessionContext.browserId}`
+ );
+ }
+ return targetActor.addOrSetSessionDataEntry(
+ type,
+ entries,
+ false,
+ updateType
+ );
+ }
+
+ _removeSessionDataEntry(watcherActorID, sessionContext, type, entries) {
+ // /!\ We may have an issue here as there could be multiple targets for a given
+ // (watcherActorID,browserId) pair.
+ // This should be clarified as part of Bug 1725623.
+ const targetActor = this._findTargetActor({
+ watcherActorID,
+ sessionContext,
+ });
+ // By the time we are calling this, the target may already have been destroyed.
+ if (targetActor) {
+ return targetActor.removeSessionDataEntry(type, entries);
+ }
+ return null;
+ }
+
+ handleEvent({ type, persisted, target }) {
+ // Ignore any event that may fire for children WindowGlobals/documents
+ if (target != this.document) {
+ return;
+ }
+
+ // DOMWindowCreated is registered from FrameWatcher via `ActorManagerParent.addJSWindowActors`
+ // as a DOM event to be listened to and so is fired by JS Window Actor code platform code.
+ if (type == "DOMWindowCreated") {
+ this.instantiate();
+ return;
+ }
+ // We might have ignored the DOMWindowCreated event because it was the initial about:blank document.
+ // But when loading same-process iframes, we reuse the WindowGlobal of the about:bank document
+ // to load the actual URL loaded in the iframe. This means we won't have a new DOMWindowCreated
+ // for the actual document. There is a DOMDocElementInserted fired just after, that we can catch
+ // to create a target for same-process iframes.
+ // This means that we still do not create any target for the initial documents.
+ // It is complex to instantiate targets for initial documents because:
+ // - it would mean spawning two targets for the same WindowGlobal and sharing the same innerWindowId
+ // - or have WindowGlobalTargets to handle more than one document (it would mean reusing will-navigate/window-ready events
+ // both on client and server)
+ if (type == "DOMDocElementInserted") {
+ this.instantiate({ ignoreIfExisting: true });
+ return;
+ }
+
+ // If persisted=true, this is a BFCache navigation.
+ //
+ // With Fission enabled and bfcacheInParent, BFCache navigations will spawn a new DocShell
+ // in the same process:
+ // * the previous page won't be destroyed, its JSWindowActor will stay alive (`didDestroy` won't be called)
+ // and a 'pagehide' with persisted=true will be emitted on it.
+ // * the new page page won't emit any DOMWindowCreated, but instead a pageshow with persisted=true
+ // will be emitted.
+
+ if (type === "pageshow" && persisted) {
+ // Notify all bfcache navigations, even the one for which we don't create a new target
+ // as that's being useful for parent process storage resource watchers.
+ this.sendAsyncMessage("DevToolsFrameChild:bf-cache-navigation-pageshow");
+
+ // Here we are going to re-instantiate a target that got destroyed before while processing a pagehide event.
+ // We force instantiating a new top level target, within `instantiate()` even if server targets are disabled.
+ // But we only do that if bfcacheInParent is enabled. Otherwise for same-process, same-docshell bfcache navigation,
+ // we don't want to spawn new targets.
+ this.instantiate({
+ isBFCache: true,
+ });
+ return;
+ }
+
+ if (type === "pagehide" && persisted) {
+ // Notify all bfcache navigations, even the one for which we don't create a new target
+ // as that's being useful for parent process storage resource watchers.
+ this.sendAsyncMessage("DevToolsFrameChild:bf-cache-navigation-pagehide");
+
+ // We might navigate away for the first top level target,
+ // which isn't using JSWindowActor (it still uses messages manager and is created by the client, via TabDescriptor.getTarget).
+ // We have to unregister it from the TargetActorRegistry, otherwise,
+ // if we navigate back to it, the next DOMWindowCreated won't create a new target for it.
+ const { sharedData } = Services.cpmm;
+ const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
+ if (!sessionDataByWatcherActor) {
+ throw new Error(
+ "Request to instantiate the target(s) for the BrowsingContext, but `sharedData` is empty about watched targets"
+ );
+ }
+
+ const actors = [];
+ // A flag to know if the following for loop ended up destroying all the actors.
+ // It may not be the case if one Watcher isn't having server target switching enabled.
+ let allActorsAreDestroyed = true;
+ for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) {
+ const { sessionContext } = sessionData;
+
+ // /!\ We may have an issue here as there could be multiple targets for a given
+ // (watcherActorID,browserId) pair.
+ // This should be clarified as part of Bug 1725623.
+ const existingTarget = this._findTargetActor({
+ watcherActorID,
+ sessionContext,
+ });
+
+ if (!existingTarget) {
+ continue;
+ }
+
+ // Use `originalWindow` as `window` can be set when a document was selected from
+ // the iframe picker in the toolbox toolbar.
+ if (existingTarget.originalWindow.document != target) {
+ throw new Error("Existing target actor is for a distinct document");
+ }
+ // Do not do anything if both bfcache in parent and server targets are disabled
+ // As history navigations will be handled within the same DocShell and by the
+ // same WindowGlobalTargetActor. The actor will listen to pageshow/pagehide by itself.
+ // We should not destroy any target.
+ if (
+ !this.isBfcacheInParentEnabled &&
+ !sessionContext.isServerTargetSwitchingEnabled
+ ) {
+ allActorsAreDestroyed = false;
+ continue;
+ }
+
+ actors.push({
+ watcherActorID,
+ form: existingTarget.form(),
+ });
+ existingTarget.destroy();
+ }
+
+ if (actors.length) {
+ // The most important is to unregister the actor from TargetActorRegistry,
+ // so that it is no longer present in the list when new DOMWindowCreated fires.
+ // This will also help notify the client that the target has been destroyed.
+ // And if we navigate back to this target, the client will receive the same target actor ID,
+ // so that it is really important to destroy it correctly on both server and client.
+ this.sendAsyncMessage("DevToolsFrameChild:destroy", { actors });
+ }
+
+ if (allActorsAreDestroyed) {
+ // Completely clear this JSWindow Actor.
+ // Do this after having called _findTargetActor,
+ // as it would clear the registered target actors.
+ this.didDestroy();
+ }
+ }
+ }
+
+ didDestroy(options) {
+ logWindowGlobal(this.manager, "Destroy WindowGlobalTarget");
+ for (const [, connectionInfo] of this._connections) {
+ connectionInfo.connection.close(options);
+ }
+ this._connections.clear();
+
+ if (this.loader) {
+ if (this.useCustomLoader) {
+ lazy.releaseDistinctSystemPrincipalLoader(this);
+ }
+ this.loader = null;
+ }
+ }
+}
diff --git a/devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs
new file mode 100644
index 0000000000..3c5af2a724
--- /dev/null
+++ b/devtools/server/connectors/js-window-actor/DevToolsFrameParent.sys.mjs
@@ -0,0 +1,279 @@
+/* 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/. */
+
+import { loader } from "resource://devtools/shared/loader/Loader.sys.mjs";
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+
+const lazy = {};
+
+loader.lazyRequireGetter(
+ lazy,
+ "JsWindowActorTransport",
+ "resource://devtools/shared/transport/js-window-actor-transport.js",
+ true
+);
+
+export class DevToolsFrameParent extends JSWindowActorParent {
+ constructor() {
+ super();
+
+ // Map of DevToolsServerConnection's used to forward the messages from/to
+ // the client. The connections run in the parent process, as this code. We
+ // may have more than one when there is more than one client debugging the
+ // same frame. For example, a content toolbox and the browser toolbox.
+ //
+ // The map is indexed by the connection prefix.
+ // The values are objects containing the following properties:
+ // - actor: the frame target actor(as a form)
+ // - connection: the DevToolsServerConnection used to communicate with the
+ // frame target actor
+ // - prefix: the forwarding prefix used by the connection to know
+ // how to forward packets to the frame target
+ // - transport: the JsWindowActorTransport
+ //
+ // Reminder about prefixes: all DevToolsServerConnections have a `prefix`
+ // which can be considered as a kind of id. On top of this, parent process
+ // DevToolsServerConnections also have forwarding prefixes because they are
+ // responsible for forwarding messages to content process connections.
+ this._connections = new Map();
+
+ this._onConnectionClosed = this._onConnectionClosed.bind(this);
+ EventEmitter.decorate(this);
+ }
+
+ /**
+ * Request the content process to create the Frame Target if there is one
+ * already available that matches the Browsing Context ID
+ */
+ async instantiateTarget({
+ watcherActorID,
+ connectionPrefix,
+ sessionContext,
+ sessionData,
+ }) {
+ await this.sendQuery("DevToolsFrameParent:instantiate-already-available", {
+ watcherActorID,
+ connectionPrefix,
+ sessionContext,
+ sessionData,
+ });
+ }
+
+ /**
+ * @param {object} arg
+ * @param {object} arg.sessionContext
+ * @param {object} arg.options
+ * @param {boolean} arg.options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+ destroyTarget({ watcherActorID, sessionContext, options }) {
+ this.sendAsyncMessage("DevToolsFrameParent:destroy", {
+ watcherActorID,
+ sessionContext,
+ options,
+ });
+ }
+
+ /**
+ * Communicate to the content process that some data have been added.
+ */
+ async addOrSetSessionDataEntry({
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType,
+ }) {
+ try {
+ await this.sendQuery("DevToolsFrameParent:addOrSetSessionDataEntry", {
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ } catch (e) {
+ console.warn(
+ "Failed to add session data entry for frame targets in browsing context",
+ this.browsingContext.id
+ );
+ console.warn(e);
+ }
+ }
+
+ /**
+ * Communicate to the content process that some data have been removed.
+ */
+ removeSessionDataEntry({ watcherActorID, sessionContext, type, entries }) {
+ this.sendAsyncMessage("DevToolsFrameParent:removeSessionDataEntry", {
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ });
+ }
+
+ connectFromContent({ watcherActorID, forwardingPrefix, actor }) {
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+
+ if (!watcher) {
+ throw new Error(
+ `Watcher Actor with ID '${watcherActorID}' can't be found.`
+ );
+ }
+ const connection = watcher.conn;
+
+ connection.on("closed", this._onConnectionClosed);
+
+ // Create a js-window-actor based transport.
+ const transport = new lazy.JsWindowActorTransport(this, forwardingPrefix);
+ transport.hooks = {
+ onPacket: connection.send.bind(connection),
+ onTransportClosed() {},
+ };
+ transport.ready();
+
+ connection.setForwarding(forwardingPrefix, transport);
+
+ this._connections.set(watcher.conn.prefix, {
+ watcher,
+ connection,
+ // This prefix is the prefix of the DevToolsServerConnection, running
+ // in the content process, for which we should forward packets to, based on its prefix.
+ // While `watcher.connection` is also a DevToolsServerConnection, but from this process,
+ // the parent process. It is the one receiving Client packets and the one, from which
+ // we should forward packets from.
+ forwardingPrefix,
+ transport,
+ actor,
+ });
+
+ watcher.notifyTargetAvailable(actor);
+ }
+
+ _onConnectionClosed(status, connectionPrefix) {
+ this._unregisterWatcher(connectionPrefix);
+ }
+
+ /**
+ * Given a watcher connection prefix, unregister everything related to the Watcher
+ * in this JSWindowActor.
+ *
+ * @param {String} connectionPrefix
+ * The connection prefix of the watcher to unregister
+ */
+ async _unregisterWatcher(connectionPrefix) {
+ const connectionInfo = this._connections.get(connectionPrefix);
+ if (!connectionInfo) {
+ return;
+ }
+ const { forwardingPrefix, transport, connection } = connectionInfo;
+ this._connections.delete(connectionPrefix);
+
+ connection.off("closed", this._onConnectionClosed);
+ if (transport) {
+ // If we have a child transport, the actor has already
+ // been created. We need to stop using this transport.
+ transport.close();
+ }
+
+ connection.cancelForwarding(forwardingPrefix);
+ }
+
+ /**
+ * Destroy everything that we did related to the current WindowGlobal that
+ * this JSWindow Actor represents:
+ * - close all transports that were used as bridge to communicate with the
+ * DevToolsFrameChild, running in the content process
+ * - unregister these transports from DevToolsServer (cancelForwarding)
+ * - notify the client, via the WatcherActor that all related targets,
+ * one per client/connection are all destroyed
+ *
+ * Note that with bfcacheInParent, we may reuse a JSWindowActor pair after closing all connections.
+ * This is can happen outside of the destruction of the actor.
+ * We may reuse a DevToolsFrameParent and DevToolsFrameChild pair.
+ * When navigating away, we will destroy them and call this method.
+ * Then when navigating back, we will reuse the same instances.
+ * So that we should be careful to keep the class fully function and only clear all its state.
+ *
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+ _closeAllConnections(options) {
+ for (const { actor, watcher } of this._connections.values()) {
+ watcher.notifyTargetDestroyed(actor, options);
+ this._unregisterWatcher(watcher.conn.prefix);
+ }
+ this._connections.clear();
+ }
+
+ /**
+ * Supported Queries
+ */
+
+ sendPacket(packet, prefix) {
+ this.sendAsyncMessage("DevToolsFrameParent:packet", { packet, prefix });
+ }
+
+ /**
+ * JsWindowActor API
+ */
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "DevToolsFrameChild:connectFromContent":
+ return this.connectFromContent(message.data);
+ case "DevToolsFrameChild:packet":
+ return this.emit("packet-received", message);
+ case "DevToolsFrameChild:destroy":
+ for (const { form, watcherActorID } of message.data.actors) {
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+ // As we instruct to destroy all targets when the watcher is destroyed,
+ // we may easily receive the target destruction notification *after*
+ // the watcher has been removed from the registry.
+ if (watcher) {
+ watcher.notifyTargetDestroyed(form, message.data.options);
+ this._unregisterWatcher(watcher.conn.prefix);
+ }
+ }
+ return null;
+ case "DevToolsFrameChild:bf-cache-navigation-pageshow":
+ for (const watcherActor of WatcherRegistry.getWatchersForBrowserId(
+ this.browsingContext.browserId
+ )) {
+ watcherActor.emit("bf-cache-navigation-pageshow", {
+ windowGlobal: this.browsingContext.currentWindowGlobal,
+ });
+ }
+ return null;
+ case "DevToolsFrameChild:bf-cache-navigation-pagehide":
+ for (const watcherActor of WatcherRegistry.getWatchersForBrowserId(
+ this.browsingContext.browserId
+ )) {
+ watcherActor.emit("bf-cache-navigation-pagehide", {
+ windowGlobal: this.browsingContext.currentWindowGlobal,
+ });
+ }
+ return null;
+ default:
+ throw new Error(
+ "Unsupported message in DevToolsFrameParent: " + message.name
+ );
+ }
+ }
+
+ didDestroy() {
+ this._closeAllConnections();
+ }
+}
diff --git a/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs
new file mode 100644
index 0000000000..6bbe4140c3
--- /dev/null
+++ b/devtools/server/connectors/js-window-actor/DevToolsWorkerChild.sys.mjs
@@ -0,0 +1,571 @@
+/* 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/. */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "wdm",
+ "@mozilla.org/dom/workers/workerdebuggermanager;1",
+ "nsIWorkerDebuggerManager"
+);
+
+ChromeUtils.defineLazyGetter(lazy, "Loader", () =>
+ ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs")
+);
+
+ChromeUtils.defineLazyGetter(lazy, "DevToolsUtils", () =>
+ lazy.Loader.require("resource://devtools/shared/DevToolsUtils.js")
+);
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ SessionDataHelpers:
+ "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm",
+});
+ChromeUtils.defineESModuleGetters(lazy, {
+ isWindowGlobalPartOfContext:
+ "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs",
+});
+
+// Name of the attribute into which we save data in `sharedData` object.
+const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher";
+
+export class DevToolsWorkerChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ // The map is indexed by the Watcher Actor ID.
+ // The values are objects containing the following properties:
+ // - connection: the DevToolsServerConnection itself
+ // - workers: An array of object containing the following properties:
+ // - dbg: A WorkerDebuggerInstance
+ // - workerTargetForm: The associated worker target instance form
+ // - workerThreadServerForwardingPrefix: The prefix used to forward events to the
+ // worker target on the worker thread ().
+ // - forwardingPrefix: Prefix used by the JSWindowActorTransport pair to communicate
+ // between content and parent processes.
+ // - sessionData: Data (targets, resources, …) the watcher wants to be notified about.
+ // See WatcherRegistry.getSessionData to see the full list of properties.
+ this._connections = new Map();
+
+ EventEmitter.decorate(this);
+ }
+
+ _onWorkerRegistered(dbg) {
+ if (!this._shouldHandleWorker(dbg)) {
+ return;
+ }
+
+ for (const [watcherActorID, { connection, forwardingPrefix }] of this
+ ._connections) {
+ this._createWorkerTargetActor({
+ dbg,
+ connection,
+ forwardingPrefix,
+ watcherActorID,
+ });
+ }
+ }
+
+ _onWorkerUnregistered(dbg) {
+ for (const [watcherActorID, { workers, forwardingPrefix }] of this
+ ._connections) {
+ // Check if the worker registration was handled for this watcherActorID.
+ const unregisteredActorIndex = workers.findIndex(worker => {
+ try {
+ // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED).
+ return worker.dbg.id === dbg.id;
+ } catch (e) {
+ return false;
+ }
+ });
+ if (unregisteredActorIndex === -1) {
+ continue;
+ }
+
+ const { workerTargetForm, transport } = workers[unregisteredActorIndex];
+ transport.close();
+
+ try {
+ this.sendAsyncMessage("DevToolsWorkerChild:workerTargetDestroyed", {
+ watcherActorID,
+ forwardingPrefix,
+ workerTargetForm,
+ });
+ } catch (e) {
+ return;
+ }
+
+ workers.splice(unregisteredActorIndex, 1);
+ }
+ }
+
+ onDOMWindowCreated() {
+ const { sharedData } = Services.cpmm;
+ const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
+ if (!sessionDataByWatcherActor) {
+ throw new Error(
+ "Request to instantiate the target(s) for the Worker, but `sharedData` is empty about watched targets"
+ );
+ }
+
+ // Create one Target actor for each prefix/client which listen to workers
+ for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) {
+ const { targets, connectionPrefix, sessionContext } = sessionData;
+ if (
+ targets?.includes("worker") &&
+ lazy.isWindowGlobalPartOfContext(this.manager, sessionContext, {
+ acceptInitialDocument: true,
+ forceAcceptTopLevelTarget: true,
+ acceptSameProcessIframes: true,
+ })
+ ) {
+ this._watchWorkerTargets({
+ watcherActorID,
+ parentConnectionPrefix: connectionPrefix,
+ sessionData,
+ });
+ }
+ }
+ }
+
+ /**
+ * Function handling messages sent by DevToolsWorkerParent (part of JSWindowActor API).
+ *
+ * @param {Object} message
+ * @param {String} message.name
+ * @param {*} message.data
+ */
+ receiveMessage(message) {
+ // All messages pass `sessionContext` (except packet) and are expected
+ // to match isWindowGlobalPartOfContext result.
+ if (message.name != "DevToolsWorkerParent:packet") {
+ const { browserId } = message.data.sessionContext;
+ // Re-check here, just to ensure that both parent and content processes agree
+ // on what should or should not be watched.
+ if (
+ this.manager.browsingContext.browserId != browserId &&
+ !lazy.isWindowGlobalPartOfContext(
+ this.manager,
+ message.data.sessionContext,
+ {
+ acceptInitialDocument: true,
+ }
+ )
+ ) {
+ throw new Error(
+ "Mismatch between DevToolsWorkerParent and DevToolsWorkerChild " +
+ (this.manager.browsingContext.browserId == browserId
+ ? "window global shouldn't be notified (isWindowGlobalPartOfContext mismatch)"
+ : `expected browsing context with ID ${browserId}, but got ${this.manager.browsingContext.browserId}`)
+ );
+ }
+ }
+
+ switch (message.name) {
+ case "DevToolsWorkerParent:instantiate-already-available": {
+ const { watcherActorID, connectionPrefix, sessionData } = message.data;
+
+ return this._watchWorkerTargets({
+ watcherActorID,
+ parentConnectionPrefix: connectionPrefix,
+ sessionData,
+ });
+ }
+ case "DevToolsWorkerParent:destroy": {
+ const { watcherActorID } = message.data;
+ return this._destroyTargetActors(watcherActorID);
+ }
+ case "DevToolsWorkerParent:addOrSetSessionDataEntry": {
+ const { watcherActorID, type, entries, updateType } = message.data;
+ return this._addOrSetSessionDataEntry(
+ watcherActorID,
+ type,
+ entries,
+ updateType
+ );
+ }
+ case "DevToolsWorkerParent:removeSessionDataEntry": {
+ const { watcherActorID, type, entries } = message.data;
+ return this._removeSessionDataEntry(watcherActorID, type, entries);
+ }
+ case "DevToolsWorkerParent:packet":
+ return this.emit("packet-received", message);
+ default:
+ throw new Error(
+ "Unsupported message in DevToolsWorkerParent: " + message.name
+ );
+ }
+ }
+
+ /**
+ * Instantiate targets for existing workers, watch for worker registration and listen
+ * for resources on those workers, for given connection and context. Targets are sent
+ * to the DevToolsWorkerParent via the DevToolsWorkerChild:workerTargetAvailable message.
+ *
+ * @param {Object} options
+ * @param {String} options.watcherActorID: The ID of the WatcherActor who requested to
+ * observe and create these target actors.
+ * @param {String} options.parentConnectionPrefix: The prefix of the DevToolsServerConnection
+ * of the Watcher Actor. This is used to compute a unique ID for the target actor.
+ * @param {Object} options.sessionData: Data (targets, resources, …) the watcher wants
+ * to be notified about. See WatcherRegistry.getSessionData to see the full list
+ * of properties.
+ */
+ async _watchWorkerTargets({
+ watcherActorID,
+ parentConnectionPrefix,
+ sessionData,
+ }) {
+ if (this._connections.has(watcherActorID)) {
+ throw new Error(
+ "DevToolsWorkerChild _watchWorkerTargets was called more than once" +
+ ` for the same Watcher (Actor ID: "${watcherActorID}")`
+ );
+ }
+
+ // Listen for new workers that will be spawned.
+ if (!this._workerDebuggerListener) {
+ this._workerDebuggerListener = {
+ onRegister: this._onWorkerRegistered.bind(this),
+ onUnregister: this._onWorkerUnregistered.bind(this),
+ };
+ lazy.wdm.addListener(this._workerDebuggerListener);
+ }
+
+ // Compute a unique prefix, just for this WindowGlobal,
+ // which will be used to create a JSWindowActorTransport pair between content and parent processes.
+ // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`,
+ // but here, we can't have access to any DevTools connection as we are really early in the content process startup
+ // WindowGlobalChild's innerWindowId should be unique across processes, so it should be safe?
+ // (this.manager == WindowGlobalChild interface)
+ const forwardingPrefix =
+ parentConnectionPrefix + "workerGlobal" + this.manager.innerWindowId;
+
+ const connection = this._createConnection(forwardingPrefix);
+
+ this._connections.set(watcherActorID, {
+ connection,
+ workers: [],
+ forwardingPrefix,
+ sessionData,
+ });
+
+ const promises = [];
+ for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
+ if (!this._shouldHandleWorker(dbg)) {
+ continue;
+ }
+ promises.push(
+ this._createWorkerTargetActor({
+ dbg,
+ connection,
+ forwardingPrefix,
+ watcherActorID,
+ })
+ );
+ }
+ await Promise.all(promises);
+ }
+
+ _createConnection(forwardingPrefix) {
+ const { DevToolsServer } = lazy.Loader.require(
+ "resource://devtools/server/devtools-server.js"
+ );
+
+ DevToolsServer.init();
+
+ // We want a special server without any root actor and only target-scoped actors.
+ // We are going to spawn a WorkerTargetActor instance in the next few lines,
+ // it is going to act like a root actor without being one.
+ DevToolsServer.registerActors({ target: true });
+
+ const connection = DevToolsServer.connectToParentWindowActor(
+ this,
+ forwardingPrefix
+ );
+
+ return connection;
+ }
+
+ /**
+ * Indicates whether or not we should handle the worker debugger
+ *
+ * @param {WorkerDebugger} dbg: The worker debugger we want to check.
+ * @returns {Boolean}
+ */
+ _shouldHandleWorker(dbg) {
+ // We only want to create targets for non-closed dedicated worker, in the same document
+ return (
+ lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg) &&
+ dbg.type === Ci.nsIWorkerDebugger.TYPE_DEDICATED &&
+ dbg.windowIDs.includes(this.manager.innerWindowId)
+ );
+ }
+
+ async _createWorkerTargetActor({
+ dbg,
+ connection,
+ forwardingPrefix,
+ watcherActorID,
+ }) {
+ // Prevent the debuggee from executing in this worker until the client has
+ // finished attaching to it. This call will throw if the debugger is already "registered"
+ // (i.e. if this is called outside of the register listener)
+ // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66
+ try {
+ dbg.setDebuggerReady(false);
+ } catch (e) {}
+
+ const watcherConnectionData = this._connections.get(watcherActorID);
+ const { sessionData } = watcherConnectionData;
+ const workerThreadServerForwardingPrefix =
+ connection.allocID("workerTarget");
+
+ // Create the actual worker target actor, in the worker thread.
+ const { connectToWorker } = lazy.Loader.require(
+ "resource://devtools/server/connectors/worker-connector.js"
+ );
+
+ const onConnectToWorker = connectToWorker(
+ connection,
+ dbg,
+ workerThreadServerForwardingPrefix,
+ {
+ sessionData,
+ sessionContext: sessionData.sessionContext,
+ }
+ );
+
+ try {
+ await onConnectToWorker;
+ } catch (e) {
+ // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to
+ // resume the debugger if it is not closed (otherwise it can cause crashes).
+ if (!dbg.isClosed) {
+ dbg.setDebuggerReady(true);
+ }
+ return;
+ }
+
+ const { workerTargetForm, transport } = await onConnectToWorker;
+
+ try {
+ this.sendAsyncMessage("DevToolsWorkerChild:workerTargetAvailable", {
+ watcherActorID,
+ forwardingPrefix,
+ workerTargetForm,
+ });
+ } catch (e) {
+ // If there was an error while sending the message, we are not going to use this
+ // connection to communicate with the worker.
+ transport.close();
+ return;
+ }
+
+ // Only add data to the connection if we successfully send the
+ // workerTargetAvailable message.
+ watcherConnectionData.workers.push({
+ dbg,
+ transport,
+ workerTargetForm,
+ workerThreadServerForwardingPrefix,
+ });
+ }
+
+ _destroyTargetActors(watcherActorID) {
+ const watcherConnectionData = this._connections.get(watcherActorID);
+ this._connections.delete(watcherActorID);
+
+ // This connection has already been cleaned?
+ if (!watcherConnectionData) {
+ console.error(
+ `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}`
+ );
+ return;
+ }
+
+ for (const {
+ dbg,
+ transport,
+ workerThreadServerForwardingPrefix,
+ } of watcherConnectionData.workers) {
+ try {
+ if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ dbg.postMessage(
+ JSON.stringify({
+ type: "disconnect",
+ forwardingPrefix: workerThreadServerForwardingPrefix,
+ })
+ );
+ }
+ } catch (e) {}
+
+ transport.close();
+ }
+
+ watcherConnectionData.connection.close();
+ }
+
+ async sendPacket(packet, prefix) {
+ return this.sendAsyncMessage("DevToolsWorkerChild:packet", {
+ packet,
+ prefix,
+ });
+ }
+
+ async _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) {
+ const watcherConnectionData = this._connections.get(watcherActorID);
+ if (!watcherConnectionData) {
+ return;
+ }
+
+ lazy.SessionDataHelpers.addOrSetSessionDataEntry(
+ watcherConnectionData.sessionData,
+ type,
+ entries,
+ updateType
+ );
+
+ const promises = [];
+ for (const {
+ dbg,
+ workerThreadServerForwardingPrefix,
+ } of watcherConnectionData.workers) {
+ promises.push(
+ addOrSetSessionDataEntryInWorkerTarget({
+ dbg,
+ workerThreadServerForwardingPrefix,
+ type,
+ entries,
+ updateType,
+ })
+ );
+ }
+ await Promise.all(promises);
+ }
+
+ _removeSessionDataEntry(watcherActorID, type, entries) {
+ const watcherConnectionData = this._connections.get(watcherActorID);
+
+ if (!watcherConnectionData) {
+ return;
+ }
+
+ lazy.SessionDataHelpers.removeSessionDataEntry(
+ watcherConnectionData.sessionData,
+ type,
+ entries
+ );
+
+ for (const {
+ dbg,
+ workerThreadServerForwardingPrefix,
+ } of watcherConnectionData.workers) {
+ if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ dbg.postMessage(
+ JSON.stringify({
+ type: "remove-session-data-entry",
+ forwardingPrefix: workerThreadServerForwardingPrefix,
+ dataEntryType: type,
+ entries,
+ })
+ );
+ }
+ }
+ }
+
+ handleEvent({ type }) {
+ // DOMWindowCreated is registered from the WatcherRegistry via `ActorManagerParent.addJSWindowActors`
+ // as a DOM event to be listened to and so is fired by JSWindowActor platform code.
+ if (type == "DOMWindowCreated") {
+ this.onDOMWindowCreated();
+ }
+ }
+
+ _removeExistingWorkerDebuggerListener() {
+ if (this._workerDebuggerListener) {
+ lazy.wdm.removeListener(this._workerDebuggerListener);
+ this._workerDebuggerListener = null;
+ }
+ }
+
+ /**
+ * Part of JSActor API
+ * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52
+ *
+ * > The didDestroy method, if present, will be called after the actor is no
+ * > longer able to receive any more messages.
+ */
+ didDestroy() {
+ this._removeExistingWorkerDebuggerListener();
+
+ for (const [watcherActorID, watcherConnectionData] of this._connections) {
+ const { connection } = watcherConnectionData;
+ this._destroyTargetActors(watcherActorID);
+
+ connection.close();
+ }
+
+ this._connections.clear();
+ }
+}
+
+/**
+ * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger.
+ *
+ * @param {WorkerDebugger} dbg
+ * @param {String} workerThreadServerForwardingPrefix
+ * @param {String} type
+ * Session data type name
+ * @param {Array} entries
+ * Session data entries to add or set.
+ * @param {String} updateType
+ * Either "add" or "set", to control if we should only add some items,
+ * or replace the whole data set with the new entries.
+ * @returns {Promise} Returns a Promise that resolves once the data entry were handled
+ * by the worker target.
+ */
+function addOrSetSessionDataEntryInWorkerTarget({
+ dbg,
+ workerThreadServerForwardingPrefix,
+ type,
+ entries,
+ updateType,
+}) {
+ if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ // Wait until we're notified by the worker that the resources are watched.
+ // This is important so we know existing resources were handled.
+ const listener = {
+ onMessage: message => {
+ message = JSON.parse(message);
+ if (message.type === "session-data-entry-added-or-set") {
+ resolve();
+ dbg.removeListener(listener);
+ }
+ },
+ // Resolve if the worker is being destroyed so we don't have a dangling promise.
+ onClose: () => resolve(),
+ };
+
+ dbg.addListener(listener);
+
+ dbg.postMessage(
+ JSON.stringify({
+ type: "add-or-set-session-data-entry",
+ forwardingPrefix: workerThreadServerForwardingPrefix,
+ dataEntryType: type,
+ entries,
+ updateType,
+ })
+ );
+ });
+}
diff --git a/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs b/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs
new file mode 100644
index 0000000000..ebe3d10ad5
--- /dev/null
+++ b/devtools/server/connectors/js-window-actor/DevToolsWorkerParent.sys.mjs
@@ -0,0 +1,300 @@
+/* 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/. */
+
+import { loader } from "resource://devtools/shared/loader/Loader.sys.mjs";
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+
+const lazy = {};
+
+loader.lazyRequireGetter(
+ lazy,
+ "JsWindowActorTransport",
+ "resource://devtools/shared/transport/js-window-actor-transport.js",
+ true
+);
+
+export class DevToolsWorkerParent extends JSWindowActorParent {
+ constructor() {
+ super();
+
+ this._destroyed = false;
+
+ // Map of DevToolsServerConnection's used to forward the messages from/to
+ // the client. The connections run in the parent process, as this code. We
+ // may have more than one when there is more than one client debugging the
+ // same worker. For example, a content toolbox and the browser toolbox.
+ //
+ // The map is indexed by the connection prefix, and the values are object with the
+ // following properties:
+ // - watcher: The WatcherActor
+ // - actors: A Map of the worker target actors form, indexed by WorkerTarget actorID
+ // - transport: the JsWindowActorTransport
+ //
+ // Reminder about prefixes: all DevToolsServerConnections have a `prefix`
+ // which can be considered as a kind of id. On top of this, parent process
+ // DevToolsServerConnections also have forwarding prefixes because they are
+ // responsible for forwarding messages to content process connections.
+ this._connections = new Map();
+
+ this._onConnectionClosed = this._onConnectionClosed.bind(this);
+ EventEmitter.decorate(this);
+ }
+
+ /**
+ * Request the content process to create Worker Targets if workers matching the context
+ * are already available.
+ */
+ async instantiateWorkerTargets({
+ watcherActorID,
+ connectionPrefix,
+ sessionContext,
+ sessionData,
+ }) {
+ try {
+ await this.sendQuery(
+ "DevToolsWorkerParent:instantiate-already-available",
+ {
+ watcherActorID,
+ connectionPrefix,
+ sessionContext,
+ sessionData,
+ }
+ );
+ } catch (e) {
+ console.warn(
+ "Failed to create DevTools Worker target for browsingContext",
+ this.browsingContext.id,
+ "and watcher actor id",
+ watcherActorID
+ );
+ console.warn(e);
+ }
+ }
+
+ destroyWorkerTargets({ watcherActorID, sessionContext }) {
+ return this.sendAsyncMessage("DevToolsWorkerParent:destroy", {
+ watcherActorID,
+ sessionContext,
+ });
+ }
+
+ /**
+ * Communicate to the content process that some data have been added.
+ */
+ async addOrSetSessionDataEntry({
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType,
+ }) {
+ try {
+ await this.sendQuery("DevToolsWorkerParent:addOrSetSessionDataEntry", {
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType,
+ });
+ } catch (e) {
+ console.warn(
+ "Failed to add session data entry for worker targets in browsing context",
+ this.browsingContext.id,
+ "and watcher actor id",
+ watcherActorID
+ );
+ console.warn(e);
+ }
+ }
+
+ /**
+ * Communicate to the content process that some data have been removed.
+ */
+ removeSessionDataEntry({ watcherActorID, sessionContext, type, entries }) {
+ this.sendAsyncMessage("DevToolsWorkerParent:removeSessionDataEntry", {
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ });
+ }
+
+ workerTargetAvailable({
+ watcherActorID,
+ forwardingPrefix,
+ workerTargetForm,
+ }) {
+ if (this._destroyed) {
+ return;
+ }
+
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+
+ if (!watcher) {
+ throw new Error(
+ `Watcher Actor with ID '${watcherActorID}' can't be found.`
+ );
+ }
+
+ const connection = watcher.conn;
+ const { prefix } = connection;
+ if (!this._connections.has(prefix)) {
+ connection.on("closed", this._onConnectionClosed);
+
+ // Create a js-window-actor based transport.
+ const transport = new lazy.JsWindowActorTransport(this, forwardingPrefix);
+ transport.hooks = {
+ onPacket: connection.send.bind(connection),
+ onTransportClosed() {},
+ };
+ transport.ready();
+
+ connection.setForwarding(forwardingPrefix, transport);
+
+ this._connections.set(prefix, {
+ watcher,
+ transport,
+ actors: new Map(),
+ });
+ }
+
+ const workerTargetActorId = workerTargetForm.actor;
+ this._connections
+ .get(prefix)
+ .actors.set(workerTargetActorId, workerTargetForm);
+ watcher.notifyTargetAvailable(workerTargetForm);
+ }
+
+ workerTargetDestroyed({
+ watcherActorID,
+ forwardingPrefix,
+ workerTargetForm,
+ }) {
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+
+ if (!watcher) {
+ throw new Error(
+ `Watcher Actor with ID '${watcherActorID}' can't be found.`
+ );
+ }
+
+ const connection = watcher.conn;
+ const { prefix } = connection;
+ if (!this._connections.has(prefix)) {
+ return;
+ }
+
+ const workerTargetActorId = workerTargetForm.actor;
+ const { actors } = this._connections.get(prefix);
+ if (!actors.has(workerTargetActorId)) {
+ return;
+ }
+
+ actors.delete(workerTargetActorId);
+ watcher.notifyTargetDestroyed(workerTargetForm);
+ }
+
+ _onConnectionClosed(status, prefix) {
+ this._unregisterWatcher(prefix);
+ }
+
+ async _unregisterWatcher(connectionPrefix) {
+ const connectionInfo = this._connections.get(connectionPrefix);
+ if (!connectionInfo) {
+ return;
+ }
+
+ const { watcher, transport } = connectionInfo;
+ const connection = watcher.conn;
+
+ connection.off("closed", this._onConnectionClosed);
+ if (transport) {
+ // If we have a child transport, the actor has already
+ // been created. We need to stop using this transport.
+ connection.cancelForwarding(transport._prefix);
+ transport.close();
+ }
+
+ this._connections.delete(connectionPrefix);
+
+ if (!this._connections.size) {
+ this._destroy();
+ }
+ }
+
+ _destroy() {
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+
+ for (const { actors, watcher } of this._connections.values()) {
+ for (const actor of actors.values()) {
+ watcher.notifyTargetDestroyed(actor);
+ }
+
+ this._unregisterWatcher(watcher.conn.prefix);
+ }
+ }
+
+ /**
+ * Part of JSActor API
+ * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52
+ *
+ * > The didDestroy method, if present, will be called after the (JSWindow)actor is no
+ * > longer able to receive any more messages.
+ */
+ didDestroy() {
+ this._destroy();
+ }
+
+ /**
+ * Supported Queries
+ */
+
+ async sendPacket(packet, prefix) {
+ return this.sendAsyncMessage("DevToolsWorkerParent:packet", {
+ packet,
+ prefix,
+ });
+ }
+
+ /**
+ * JsWindowActor API
+ */
+
+ async sendQuery(msg, args) {
+ try {
+ const res = await super.sendQuery(msg, args);
+ return res;
+ } catch (e) {
+ console.error("Failed to sendQuery in DevToolsWorkerParent", msg, e);
+ throw e;
+ }
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "DevToolsWorkerChild:workerTargetAvailable":
+ return this.workerTargetAvailable(message.data);
+ case "DevToolsWorkerChild:workerTargetDestroyed":
+ return this.workerTargetDestroyed(message.data);
+ case "DevToolsWorkerChild:packet":
+ return this.emit("packet-received", message);
+ default:
+ throw new Error(
+ "Unsupported message in DevToolsWorkerParent: " + message.name
+ );
+ }
+ }
+}
diff --git a/devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs b/devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs
new file mode 100644
index 0000000000..ae15c030fe
--- /dev/null
+++ b/devtools/server/connectors/js-window-actor/WindowGlobalLogger.sys.mjs
@@ -0,0 +1,76 @@
+/* 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/. */
+
+function getWindowGlobalUri(windowGlobal) {
+ let windowGlobalUri = "";
+
+ if (windowGlobal.documentURI) {
+ // If windowGlobal is a WindowGlobalParent documentURI should be available.
+ windowGlobalUri = windowGlobal.documentURI.spec;
+ } else if (windowGlobal.browsingContext?.window) {
+ // If windowGlobal is a WindowGlobalChild, this code runs in the same
+ // process as the document and we can directly access the window.location
+ // object.
+ windowGlobalUri = windowGlobal.browsingContext.window.location.href;
+ if (!windowGlobalUri) {
+ windowGlobalUri =
+ windowGlobal.browsingContext.window.document.documentURI;
+ }
+ }
+
+ return windowGlobalUri;
+}
+
+export const WindowGlobalLogger = {
+ /**
+ * This logger can run from the content or parent process, and windowGlobal
+ * will either be of type `WindowGlobalParent` or `WindowGlobalChild`.
+ *
+ * The interface for each type can be found in WindowGlobalActors.webidl
+ * (https://searchfox.org/mozilla-central/source/dom/chrome-webidl/WindowGlobalActors.webidl)
+ *
+ * @param {WindowGlobalParent|WindowGlobalChild} windowGlobal
+ * The window global to log. See WindowGlobalActors.webidl for details
+ * about the types.
+ * @param {String} message
+ * A custom message that will be displayed at the beginning of the log.
+ */
+ logWindowGlobal(windowGlobal, message) {
+ const { browsingContext } = windowGlobal;
+ const { parent } = browsingContext;
+ const windowGlobalUri = getWindowGlobalUri(windowGlobal);
+ const isInitialDocument =
+ "isInitialDocument" in windowGlobal
+ ? windowGlobal.isInitialDocument
+ : windowGlobal.browsingContext.window?.document.isInitialDocument;
+
+ const details = [];
+ details.push(
+ "BrowsingContext.browserId: " + browsingContext.browserId,
+ "BrowsingContext.id: " + browsingContext.id,
+ "innerWindowId: " + windowGlobal.innerWindowId,
+ "opener.id: " + browsingContext.opener?.id,
+ "pid: " + windowGlobal.osPid,
+ "isClosed: " + windowGlobal.isClosed,
+ "isInProcess: " + windowGlobal.isInProcess,
+ "isCurrentGlobal: " + windowGlobal.isCurrentGlobal,
+ "isProcessRoot: " + windowGlobal.isProcessRoot,
+ "currentRemoteType: " + browsingContext.currentRemoteType,
+ "hasParent: " + (parent ? parent.id : "no"),
+ "uri: " + (windowGlobalUri ? windowGlobalUri : "no uri"),
+ "isProcessRoot: " + windowGlobal.isProcessRoot,
+ "BrowsingContext.isContent: " + windowGlobal.browsingContext.isContent,
+ "isInitialDocument: " + isInitialDocument
+ );
+
+ const header = "[WindowGlobalLogger] " + message;
+
+ // Use a padding for multiline display.
+ const padding = " ";
+ const formattedDetails = details.map(s => padding + s);
+ const detailsString = formattedDetails.join("\n");
+
+ dump(header + "\n" + detailsString + "\n");
+ },
+};
diff --git a/devtools/server/connectors/js-window-actor/moz.build b/devtools/server/connectors/js-window-actor/moz.build
new file mode 100644
index 0000000000..faaaa8dd54
--- /dev/null
+++ b/devtools/server/connectors/js-window-actor/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/.
+
+DevToolsModules(
+ "DevToolsFrameChild.sys.mjs",
+ "DevToolsFrameParent.sys.mjs",
+ "DevToolsWorkerChild.sys.mjs",
+ "DevToolsWorkerParent.sys.mjs",
+ "WindowGlobalLogger.sys.mjs",
+)
diff --git a/devtools/server/connectors/moz.build b/devtools/server/connectors/moz.build
new file mode 100644
index 0000000000..a8b6fa1fea
--- /dev/null
+++ b/devtools/server/connectors/moz.build
@@ -0,0 +1,16 @@
+# -*- 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 += [
+ "js-window-actor",
+ "process-actor",
+]
+
+DevToolsModules(
+ "content-process-connector.js",
+ "frame-connector.js",
+ "worker-connector.js",
+)
diff --git a/devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs b/devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs
new file mode 100644
index 0000000000..2e461cbd03
--- /dev/null
+++ b/devtools/server/connectors/process-actor/DevToolsServiceWorkerChild.sys.mjs
@@ -0,0 +1,741 @@
+/* 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/. */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ loader: "resource://devtools/shared/loader/Loader.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "wdm",
+ "@mozilla.org/dom/workers/workerdebuggermanager;1",
+ "nsIWorkerDebuggerManager"
+);
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ SessionDataHelpers:
+ "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "DevToolsUtils", () =>
+ lazy.loader.require("devtools/shared/DevToolsUtils")
+);
+
+// Name of the attribute into which we save data in `sharedData` object.
+const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher";
+
+export class DevToolsServiceWorkerChild extends JSProcessActorChild {
+ constructor() {
+ super();
+
+ // The map is indexed by the Watcher Actor ID.
+ // The values are objects containing the following properties:
+ // - connection: the DevToolsServerConnection itself
+ // - workers: An array of object containing the following properties:
+ // - dbg: A WorkerDebuggerInstance
+ // - serviceWorkerTargetForm: The associated worker target instance form
+ // - workerThreadServerForwardingPrefix: The prefix used to forward events to the
+ // worker target on the worker thread ().
+ // - forwardingPrefix: Prefix used by the JSWindowActorTransport pair to communicate
+ // between content and parent processes.
+ // - sessionData: Data (targets, resources, …) the watcher wants to be notified about.
+ // See WatcherRegistry.getSessionData to see the full list of properties.
+ this._connections = new Map();
+
+ this._onConnectionChange = this._onConnectionChange.bind(this);
+
+ EventEmitter.decorate(this);
+ }
+
+ /**
+ * Called by nsIWorkerDebuggerManager when a worker get created.
+ *
+ * Go through all registered connections (in case we have more than one client connected)
+ * to eventually instantiate a target actor for this worker.
+ *
+ * @param {nsIWorkerDebugger} dbg
+ */
+ _onWorkerRegistered(dbg) {
+ // Only consider service workers
+ if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
+ return;
+ }
+
+ for (const [
+ watcherActorID,
+ { connection, forwardingPrefix, sessionData },
+ ] of this._connections) {
+ if (this._shouldHandleWorker(sessionData, dbg)) {
+ this._createWorkerTargetActor({
+ dbg,
+ connection,
+ forwardingPrefix,
+ watcherActorID,
+ });
+ }
+ }
+ }
+
+ /**
+ * Called by nsIWorkerDebuggerManager when a worker get destroyed.
+ *
+ * Go through all registered connections (in case we have more than one client connected)
+ * to destroy the related target which may have been created for this worker.
+ *
+ * @param {nsIWorkerDebugger} dbg
+ */
+ _onWorkerUnregistered(dbg) {
+ // Only consider service workers
+ if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
+ return;
+ }
+
+ for (const [watcherActorID, watcherConnectionData] of this._connections) {
+ this._destroyServiceWorkerTargetForWatcher(
+ watcherActorID,
+ watcherConnectionData,
+ dbg
+ );
+ }
+ }
+
+ /**
+ * To be called when we know a Service Worker target should be destroyed for a specific connection
+ * for which we pass the related "watcher connection data".
+ *
+ * @param {String} watcherActorID
+ * Watcher actor ID for which we should unregister this service worker.
+ * @param {Object} watcherConnectionData
+ * The metadata object for a given watcher, stored in the _connections Map.
+ * @param {nsIWorkerDebugger} dbg
+ */
+ _destroyServiceWorkerTargetForWatcher(
+ watcherActorID,
+ watcherConnectionData,
+ dbg
+ ) {
+ const { workers, forwardingPrefix } = watcherConnectionData;
+
+ // Check if the worker registration was handled for this watcher.
+ const unregisteredActorIndex = workers.findIndex(worker => {
+ try {
+ // Accessing the WorkerDebugger id might throw (NS_ERROR_UNEXPECTED).
+ return worker.dbg.id === dbg.id;
+ } catch (e) {
+ return false;
+ }
+ });
+
+ // Ignore this worker if it wasn't registered for this watcher.
+ if (unregisteredActorIndex === -1) {
+ return;
+ }
+
+ const { serviceWorkerTargetForm, transport } =
+ workers[unregisteredActorIndex];
+
+ // Remove the entry from this._connection dictionnary
+ workers.splice(unregisteredActorIndex, 1);
+
+ // Close the transport made against the worker thread.
+ transport.close();
+
+ // Note that we do not need to post the "disconnect" message from this destruction codepath
+ // as this method is only called when the worker is unregistered and so,
+ // we can't send any message anyway, and the worker is being destroyed anyway.
+
+ // Also notify the parent process that this worker target got destroyed.
+ // As the worker thread may be already destroyed, it may not have time to send a destroy event.
+ try {
+ this.sendAsyncMessage(
+ "DevToolsServiceWorkerChild:serviceWorkerTargetDestroyed",
+ {
+ watcherActorID,
+ forwardingPrefix,
+ serviceWorkerTargetForm,
+ }
+ );
+ } catch (e) {
+ // Ignore exception which may happen on content process destruction
+ }
+ }
+
+ /**
+ * Function handling messages sent by DevToolsServiceWorkerParent (part of ProcessActor API).
+ *
+ * @param {Object} message
+ * @param {String} message.name
+ * @param {*} message.data
+ */
+ receiveMessage(message) {
+ switch (message.name) {
+ case "DevToolsServiceWorkerParent:instantiate-already-available": {
+ const { watcherActorID, connectionPrefix, sessionData } = message.data;
+ return this._watchWorkerTargets({
+ watcherActorID,
+ parentConnectionPrefix: connectionPrefix,
+ sessionData,
+ });
+ }
+ case "DevToolsServiceWorkerParent:destroy": {
+ const { watcherActorID } = message.data;
+ return this._destroyTargetActors(watcherActorID);
+ }
+ case "DevToolsServiceWorkerParent:addOrSetSessionDataEntry": {
+ const { watcherActorID, type, entries, updateType } = message.data;
+ return this._addOrSetSessionDataEntry(
+ watcherActorID,
+ type,
+ entries,
+ updateType
+ );
+ }
+ case "DevToolsServiceWorkerParent:removeSessionDataEntry": {
+ const { watcherActorID, type, entries } = message.data;
+ return this._removeSessionDataEntry(watcherActorID, type, entries);
+ }
+ case "DevToolsServiceWorkerParent:packet":
+ return this.emit("packet-received", message);
+ default:
+ throw new Error(
+ "Unsupported message in DevToolsServiceWorkerParent: " + message.name
+ );
+ }
+ }
+
+ /**
+ * "chrome-event-target-created" event handler. Supposed to be fired very early when the process starts
+ */
+ observe() {
+ const { sharedData } = Services.cpmm;
+ const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
+ if (!sessionDataByWatcherActor) {
+ throw new Error(
+ "Request to instantiate the target(s) for the Service Worker, but `sharedData` is empty about watched targets"
+ );
+ }
+
+ // Create one Target actor for each prefix/client which listen to workers
+ for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) {
+ const { targets, connectionPrefix } = sessionData;
+ if (targets?.includes("service_worker")) {
+ this._watchWorkerTargets({
+ watcherActorID,
+ parentConnectionPrefix: connectionPrefix,
+ sessionData,
+ });
+ }
+ }
+ }
+
+ /**
+ * Instantiate targets for existing workers, watch for worker registration and listen
+ * for resources on those workers, for given connection and context. Targets are sent
+ * to the DevToolsServiceWorkerParent via the DevToolsServiceWorkerChild:serviceWorkerTargetAvailable message.
+ *
+ * @param {Object} options
+ * @param {String} options.watcherActorID: The ID of the WatcherActor who requested to
+ * observe and create these target actors.
+ * @param {String} options.parentConnectionPrefix: The prefix of the DevToolsServerConnection
+ * of the Watcher Actor. This is used to compute a unique ID for the target actor.
+ * @param {Object} options.sessionData: Data (targets, resources, …) the watcher wants
+ * to be notified about. See WatcherRegistry.getSessionData to see the full list
+ * of properties.
+ */
+ async _watchWorkerTargets({
+ watcherActorID,
+ parentConnectionPrefix,
+ sessionData,
+ }) {
+ // We might already have been called from observe method if the process was initializing
+ if (this._connections.has(watcherActorID)) {
+ // In such case, wait for the promise in order to ensure resolving only after
+ // we notified about the existing targets
+ await this._connections.get(watcherActorID).watchPromise;
+ return;
+ }
+
+ // Compute a unique prefix, just for this Service Worker,
+ // which will be used to create a JSWindowActorTransport pair between content and parent processes.
+ // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`,
+ // but here, we can't have access to any DevTools connection as we are really early in the content process startup
+ // WindowGlobalChild's innerWindowId should be unique across processes, so it should be safe?
+ // (this.manager == WindowGlobalChild interface)
+ const forwardingPrefix =
+ parentConnectionPrefix + "serviceWorkerProcess" + this.manager.childID;
+
+ const connection = this._createConnection(forwardingPrefix);
+
+ // This method will be concurrently called from `observe()` and `DevToolsServiceWorkerParent:instantiate-already-available`
+ // When the JSprocessActor initializes itself and when the watcher want to force instantiating existing targets.
+ // Wait for the existing promise when the second call arise.
+ //
+ // Also, _connections has to be populated *before* calling _createWorkerTargetActor,
+ // so create a deferred promise right away.
+ let resolveWatchPromise;
+ const watchPromise = new Promise(
+ resolve => (resolveWatchPromise = resolve)
+ );
+
+ this._connections.set(watcherActorID, {
+ connection,
+ watchPromise,
+ workers: [],
+ forwardingPrefix,
+ sessionData,
+ });
+
+ // Listen for new workers that will be spawned.
+ if (!this._workerDebuggerListener) {
+ this._workerDebuggerListener = {
+ onRegister: this._onWorkerRegistered.bind(this),
+ onUnregister: this._onWorkerUnregistered.bind(this),
+ };
+ lazy.wdm.addListener(this._workerDebuggerListener);
+ }
+
+ const promises = [];
+ for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
+ if (!this._shouldHandleWorker(sessionData, dbg)) {
+ continue;
+ }
+ promises.push(
+ this._createWorkerTargetActor({
+ dbg,
+ connection,
+ forwardingPrefix,
+ watcherActorID,
+ })
+ );
+ }
+ await Promise.all(promises);
+ resolveWatchPromise();
+ }
+
+ /**
+ * Initialize a DevTools Server and return a new DevToolsServerConnection
+ * using this server in order to communicate to the parent process via
+ * the JSProcessActor message / queries.
+ *
+ * @param String forwardingPrefix
+ * A unique prefix used to distinguish message coming from distinct service workers.
+ * @return DevToolsServerConnection
+ * A connection to communicate with the parent process.
+ */
+ _createConnection(forwardingPrefix) {
+ const { DevToolsServer } = lazy.loader.require(
+ "devtools/server/devtools-server"
+ );
+
+ DevToolsServer.init();
+
+ // We want a special server without any root actor and only target-scoped actors.
+ // We are going to spawn a WorkerTargetActor instance in the next few lines,
+ // it is going to act like a root actor without being one.
+ DevToolsServer.registerActors({ target: true });
+ DevToolsServer.on("connectionchange", this._onConnectionChange);
+
+ const connection = DevToolsServer.connectToParentWindowActor(
+ this,
+ forwardingPrefix
+ );
+
+ return connection;
+ }
+
+ /**
+ * Indicates whether or not we should handle the worker debugger for a given
+ * watcher's session data.
+ *
+ * @param {Object} sessionData
+ * The session data for a given watcher, which includes metadata
+ * about the debugged context.
+ * @param {WorkerDebugger} dbg
+ * The worker debugger we want to check.
+ *
+ * @returns {Boolean}
+ */
+ _shouldHandleWorker(sessionData, dbg) {
+ if (dbg.type !== Ci.nsIWorkerDebugger.TYPE_SERVICE) {
+ return false;
+ }
+ // We only want to create targets for non-closed service worker
+ if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ return false;
+ }
+
+ // Accessing `nsIPrincipal.host` may easily throw on non-http URLs.
+ // Ignore all non-HTTP as they most likely don't have any valid host name.
+ if (!dbg.principal.scheme.startsWith("http")) {
+ return false;
+ }
+
+ const workerHost = dbg.principal.hostPort;
+ return workerHost == sessionData["browser-element-host"][0];
+ }
+
+ async _createWorkerTargetActor({
+ dbg,
+ connection,
+ forwardingPrefix,
+ watcherActorID,
+ }) {
+ // Freeze the worker execution as soon as possible in order to wait for DevTools bootstrap.
+ // We typically want to:
+ // - startup the Thread Actor,
+ // - pass the initial session data which includes breakpoints to the worker thread,
+ // - register the breakpoints,
+ // before release its execution.
+ // `connectToWorker` is going to call setDebuggerReady(true) when all of this is done.
+ try {
+ dbg.setDebuggerReady(false);
+ } catch (e) {
+ // This call will throw if the debugger is already "registered"
+ // (i.e. if this is called outside of the register listener)
+ // See https://searchfox.org/mozilla-central/rev/84922363f4014eae684aabc4f1d06380066494c5/dom/workers/nsIWorkerDebugger.idl#55-66
+ }
+
+ const watcherConnectionData = this._connections.get(watcherActorID);
+ const { sessionData } = watcherConnectionData;
+ const workerThreadServerForwardingPrefix = connection.allocID(
+ "serviceWorkerTarget"
+ );
+
+ // Create the actual worker target actor, in the worker thread.
+ const { connectToWorker } = lazy.loader.require(
+ "devtools/server/connectors/worker-connector"
+ );
+
+ const onConnectToWorker = connectToWorker(
+ connection,
+ dbg,
+ workerThreadServerForwardingPrefix,
+ {
+ sessionData,
+ sessionContext: sessionData.sessionContext,
+ }
+ );
+
+ try {
+ await onConnectToWorker;
+ } catch (e) {
+ // connectToWorker is supposed to call setDebuggerReady(true) to release the worker execution.
+ // But if anything goes wrong and an exception is thrown, ensure releasing its execution,
+ // otherwise if devtools is broken, it will freeze the worker indefinitely.
+ //
+ // onConnectToWorker can reject if the Worker Debugger is closed; so we only want to
+ // resume the debugger if it is not closed (otherwise it can cause crashes).
+ if (!dbg.isClosed) {
+ dbg.setDebuggerReady(true);
+ }
+ return;
+ }
+
+ const { workerTargetForm, transport } = await onConnectToWorker;
+
+ try {
+ this.sendAsyncMessage(
+ "DevToolsServiceWorkerChild:serviceWorkerTargetAvailable",
+ {
+ watcherActorID,
+ forwardingPrefix,
+ serviceWorkerTargetForm: workerTargetForm,
+ }
+ );
+ } catch (e) {
+ // If there was an error while sending the message, we are not going to use this
+ // connection to communicate with the worker.
+ transport.close();
+ return;
+ }
+
+ // Only add data to the connection if we successfully send the
+ // serviceWorkerTargetAvailable message.
+ watcherConnectionData.workers.push({
+ dbg,
+ transport,
+ serviceWorkerTargetForm: workerTargetForm,
+ workerThreadServerForwardingPrefix,
+ });
+ }
+
+ /**
+ * Request the service worker threads to destroy all their service worker Targets currently registered for a given Watcher actor.
+ *
+ * @param {String} watcherActorID
+ */
+ _destroyTargetActors(watcherActorID) {
+ const watcherConnectionData = this._connections.get(watcherActorID);
+ this._connections.delete(watcherActorID);
+
+ // This connection has already been cleaned?
+ if (!watcherConnectionData) {
+ console.error(
+ `Trying to destroy a target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}`
+ );
+ return;
+ }
+
+ for (const {
+ dbg,
+ transport,
+ workerThreadServerForwardingPrefix,
+ } of watcherConnectionData.workers) {
+ try {
+ if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ dbg.postMessage(
+ JSON.stringify({
+ type: "disconnect",
+ forwardingPrefix: workerThreadServerForwardingPrefix,
+ })
+ );
+ }
+ } catch (e) {}
+
+ transport.close();
+ }
+
+ watcherConnectionData.connection.close();
+ }
+
+ /**
+ * Destroy the server once its last connection closes. Note that multiple
+ * worker scripts may be running in parallel and reuse the same server.
+ */
+ _onConnectionChange() {
+ const { DevToolsServer } = lazy.loader.require(
+ "devtools/server/devtools-server"
+ );
+
+ // Only destroy the server if there is no more connections to it. It may be
+ // used to debug another tab running in the same process.
+ if (DevToolsServer.hasConnection() || DevToolsServer.keepAlive) {
+ return;
+ }
+
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+
+ DevToolsServer.off("connectionchange", this._onConnectionChange);
+ DevToolsServer.destroy();
+ }
+
+ /**
+ * Used by DevTools transport layer to communicate with the parent process.
+ *
+ * @param {String} packet
+ * @param {String prefix
+ */
+ async sendPacket(packet, prefix) {
+ return this.sendAsyncMessage("DevToolsServiceWorkerChild:packet", {
+ packet,
+ prefix,
+ });
+ }
+
+ /**
+ * Go through all registered service workers for a given watcher actor
+ * to send them new session data entries.
+ *
+ * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments.
+ */
+ async _addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) {
+ const watcherConnectionData = this._connections.get(watcherActorID);
+ if (!watcherConnectionData) {
+ return;
+ }
+
+ lazy.SessionDataHelpers.addOrSetSessionDataEntry(
+ watcherConnectionData.sessionData,
+ type,
+ entries,
+ updateType
+ );
+
+ // This type is really specific to Service Workers and doesn't need to be transferred to the worker threads.
+ // We only need to instantiate and destroy the target actors based on this new host.
+ if (type == "browser-element-host") {
+ this.updateBrowserElementHost(watcherActorID, watcherConnectionData);
+ return;
+ }
+
+ const promises = [];
+ for (const {
+ dbg,
+ workerThreadServerForwardingPrefix,
+ } of watcherConnectionData.workers) {
+ promises.push(
+ addOrSetSessionDataEntryInWorkerTarget({
+ dbg,
+ workerThreadServerForwardingPrefix,
+ type,
+ entries,
+ updateType,
+ })
+ );
+ }
+ await Promise.all(promises);
+ }
+
+ /**
+ * Called whenever the debugged browser element navigates to a new page
+ * and the URL's host changes.
+ * This is used to maintain the list of active Service Worker targets
+ * based on that host name.
+ *
+ * @param {String} watcherActorID
+ * Watcher actor ID for which we should unregister this service worker.
+ * @param {Object} watcherConnectionData
+ * The metadata object for a given watcher, stored in the _connections Map.
+ */
+ async updateBrowserElementHost(watcherActorID, watcherConnectionData) {
+ const { sessionData, connection, forwardingPrefix } = watcherConnectionData;
+
+ // Create target actor matching this new host.
+ // Note that we may be navigating to the same host name and the target will already exist.
+ const dbgToInstantiate = [];
+ for (const dbg of lazy.wdm.getWorkerDebuggerEnumerator()) {
+ const alreadyCreated = watcherConnectionData.workers.some(
+ info => info.dbg === dbg
+ );
+ if (this._shouldHandleWorker(sessionData, dbg) && !alreadyCreated) {
+ dbgToInstantiate.push(dbg);
+ }
+ }
+ await Promise.all(
+ dbgToInstantiate.map(dbg => {
+ return this._createWorkerTargetActor({
+ dbg,
+ connection,
+ forwardingPrefix,
+ watcherActorID,
+ });
+ })
+ );
+ }
+
+ /**
+ * Go through all registered service workers for a given watcher actor
+ * to send them request to clear some session data entries.
+ *
+ * See addOrSetSessionDataEntryInWorkerTarget for more info about arguments.
+ */
+ _removeSessionDataEntry(watcherActorID, type, entries) {
+ const watcherConnectionData = this._connections.get(watcherActorID);
+
+ if (!watcherConnectionData) {
+ return;
+ }
+
+ lazy.SessionDataHelpers.removeSessionDataEntry(
+ watcherConnectionData.sessionData,
+ type,
+ entries
+ );
+
+ for (const {
+ dbg,
+ workerThreadServerForwardingPrefix,
+ } of watcherConnectionData.workers) {
+ if (lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ dbg.postMessage(
+ JSON.stringify({
+ type: "remove-session-data-entry",
+ forwardingPrefix: workerThreadServerForwardingPrefix,
+ dataEntryType: type,
+ entries,
+ })
+ );
+ }
+ }
+ }
+
+ _removeExistingWorkerDebuggerListener() {
+ if (this._workerDebuggerListener) {
+ lazy.wdm.removeListener(this._workerDebuggerListener);
+ this._workerDebuggerListener = null;
+ }
+ }
+
+ /**
+ * Part of JSActor API
+ * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52
+ *
+ * > The didDestroy method, if present, will be called after the actor is no
+ * > longer able to receive any more messages.
+ */
+ didDestroy() {
+ this._removeExistingWorkerDebuggerListener();
+
+ for (const [watcherActorID, watcherConnectionData] of this._connections) {
+ const { connection } = watcherConnectionData;
+ this._destroyTargetActors(watcherActorID);
+
+ connection.close();
+ }
+
+ this._connections.clear();
+ }
+}
+
+/**
+ * Communicate the type and entries to the Worker Target actor, via the WorkerDebugger.
+ *
+ * @param {WorkerDebugger} dbg
+ * @param {String} workerThreadServerForwardingPrefix
+ * @param {String} type
+ * Session data type name
+ * @param {Array} entries
+ * Session data entries to add or set.
+ * @param {String} updateType
+ * Either "add" or "set", to control if we should only add some items,
+ * or replace the whole data set with the new entries.
+ * @returns {Promise} Returns a Promise that resolves once the data entry were handled
+ * by the worker target.
+ */
+function addOrSetSessionDataEntryInWorkerTarget({
+ dbg,
+ workerThreadServerForwardingPrefix,
+ type,
+ entries,
+ updateType,
+}) {
+ if (!lazy.DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ // Wait until we're notified by the worker that the resources are watched.
+ // This is important so we know existing resources were handled.
+ const listener = {
+ onMessage: message => {
+ message = JSON.parse(message);
+ if (message.type === "session-data-entry-added-or-set") {
+ resolve();
+ dbg.removeListener(listener);
+ }
+ },
+ // Resolve if the worker is being destroyed so we don't have a dangling promise.
+ onClose: () => resolve(),
+ };
+
+ dbg.addListener(listener);
+
+ dbg.postMessage(
+ JSON.stringify({
+ type: "add-or-set-session-data-entry",
+ forwardingPrefix: workerThreadServerForwardingPrefix,
+ dataEntryType: type,
+ entries,
+ updateType,
+ })
+ );
+ });
+}
diff --git a/devtools/server/connectors/process-actor/DevToolsServiceWorkerParent.sys.mjs b/devtools/server/connectors/process-actor/DevToolsServiceWorkerParent.sys.mjs
new file mode 100644
index 0000000000..17fa89e7ac
--- /dev/null
+++ b/devtools/server/connectors/process-actor/DevToolsServiceWorkerParent.sys.mjs
@@ -0,0 +1,314 @@
+/* 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/. */
+
+import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const { WatcherRegistry } = ChromeUtils.importESModule(
+ "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
+ {
+ // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
+ // which also has to be a true singleton.
+ loadInDevToolsLoader: false,
+ }
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ loader: "resource://devtools/shared/loader/Loader.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(
+ lazy,
+ "JsWindowActorTransport",
+ () =>
+ lazy.loader.require("devtools/shared/transport/js-window-actor-transport")
+ .JsWindowActorTransport
+);
+
+export class DevToolsServiceWorkerParent extends JSProcessActorParent {
+ constructor() {
+ super();
+
+ this._destroyed = false;
+
+ // Map of DevToolsServerConnection's used to forward the messages from/to
+ // the client. The connections run in the parent process, as this code. We
+ // may have more than one when there is more than one client debugging the
+ // same worker. For example, a content toolbox and the browser toolbox.
+ //
+ // The map is indexed by the connection prefix, and the values are object with the
+ // following properties:
+ // - watcher: The WatcherActor
+ // - actors: A Map of the worker target actors form, indexed by WorkerTarget actorID
+ // - transport: the JsWindowActorTransport
+ //
+ // Reminder about prefixes: all DevToolsServerConnections have a `prefix`
+ // which can be considered as a kind of id. On top of this, parent process
+ // DevToolsServerConnections also have forwarding prefixes because they are
+ // responsible for forwarding messages to content process connections.
+ this._connections = new Map();
+
+ this._onConnectionClosed = this._onConnectionClosed.bind(this);
+ EventEmitter.decorate(this);
+ }
+
+ /**
+ * Request the content process to create Service Worker Targets if workers matching the context
+ * are already available.
+ */
+ async instantiateServiceWorkerTargets({
+ watcherActorID,
+ connectionPrefix,
+ sessionContext,
+ sessionData,
+ }) {
+ try {
+ await this.sendQuery(
+ "DevToolsServiceWorkerParent:instantiate-already-available",
+ {
+ watcherActorID,
+ connectionPrefix,
+ sessionContext,
+ sessionData,
+ }
+ );
+ } catch (e) {
+ console.warn(
+ "Failed to create DevTools Service Worker target for process",
+ this.manager.osPid,
+ "and watcher actor id",
+ watcherActorID
+ );
+ console.warn(e);
+ }
+ }
+
+ destroyServiceWorkerTargets({ watcherActorID, sessionContext }) {
+ return this.sendAsyncMessage("DevToolsServiceWorkerParent:destroy", {
+ watcherActorID,
+ sessionContext,
+ });
+ }
+
+ /**
+ * Communicate to the content process that some data have been added.
+ */
+ async addOrSetSessionDataEntry({
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType,
+ }) {
+ try {
+ await this.sendQuery(
+ "DevToolsServiceWorkerParent:addOrSetSessionDataEntry",
+ {
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ updateType,
+ }
+ );
+ } catch (e) {
+ console.warn(
+ "Failed to add session data entry for worker targets in process",
+ this.manager.osPid,
+ "and watcher actor id",
+ watcherActorID
+ );
+ console.warn(e);
+ }
+ }
+
+ /**
+ * Communicate to the content process that some data have been removed.
+ */
+ removeSessionDataEntry({ watcherActorID, sessionContext, type, entries }) {
+ this.sendAsyncMessage(
+ "DevToolsServiceWorkerParent:removeSessionDataEntry",
+ {
+ watcherActorID,
+ sessionContext,
+ type,
+ entries,
+ }
+ );
+ }
+
+ serviceWorkerTargetAvailable({
+ watcherActorID,
+ forwardingPrefix,
+ serviceWorkerTargetForm,
+ }) {
+ if (this._destroyed) {
+ return;
+ }
+
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+
+ if (!watcher) {
+ throw new Error(
+ `Watcher Actor with ID '${watcherActorID}' can't be found.`
+ );
+ }
+
+ const connection = watcher.conn;
+ const { prefix } = connection;
+ if (!this._connections.has(prefix)) {
+ connection.on("closed", this._onConnectionClosed);
+
+ // Create a js-window-actor based transport.
+ const transport = new lazy.JsWindowActorTransport(this, forwardingPrefix);
+ transport.hooks = {
+ onPacket: connection.send.bind(connection),
+ onTransportClosed() {},
+ };
+ transport.ready();
+
+ connection.setForwarding(forwardingPrefix, transport);
+
+ this._connections.set(prefix, {
+ connection,
+ watcher,
+ transport,
+ actors: new Map(),
+ });
+ }
+
+ const serviceWorkerTargetActorId = serviceWorkerTargetForm.actor;
+ this._connections
+ .get(prefix)
+ .actors.set(serviceWorkerTargetActorId, serviceWorkerTargetForm);
+ watcher.notifyTargetAvailable(serviceWorkerTargetForm);
+ }
+
+ serviceWorkerTargetDestroyed({
+ watcherActorID,
+ forwardingPrefix,
+ serviceWorkerTargetForm,
+ }) {
+ const watcher = WatcherRegistry.getWatcher(watcherActorID);
+
+ if (!watcher) {
+ throw new Error(
+ `Watcher Actor with ID '${watcherActorID}' can't be found.`
+ );
+ }
+
+ const connection = watcher.conn;
+ const { prefix } = connection;
+ if (!this._connections.has(prefix)) {
+ return;
+ }
+
+ const serviceWorkerTargetActorId = serviceWorkerTargetForm.actor;
+ const { actors } = this._connections.get(prefix);
+ if (!actors.has(serviceWorkerTargetActorId)) {
+ return;
+ }
+
+ actors.delete(serviceWorkerTargetActorId);
+ watcher.notifyTargetDestroyed(serviceWorkerTargetForm);
+ }
+
+ _onConnectionClosed(status, prefix) {
+ if (this._connections.has(prefix)) {
+ const { connection } = this._connections.get(prefix);
+ this._cleanupConnection(connection);
+ }
+ }
+
+ async _cleanupConnection(connection) {
+ if (!this._connections || !this._connections.has(connection.prefix)) {
+ return;
+ }
+
+ const { transport } = this._connections.get(connection.prefix);
+
+ connection.off("closed", this._onConnectionClosed);
+ if (transport) {
+ // If we have a child transport, the actor has already
+ // been created. We need to stop using this transport.
+ connection.cancelForwarding(transport._prefix);
+ transport.close();
+ }
+
+ this._connections.delete(connection.prefix);
+ if (!this._connections.size) {
+ this._destroy();
+ }
+ }
+
+ _destroy() {
+ if (this._destroyed) {
+ return;
+ }
+ this._destroyed = true;
+
+ for (const { actors, watcher } of this._connections.values()) {
+ for (const actor of actors.values()) {
+ watcher.notifyTargetDestroyed(actor);
+ }
+
+ this._cleanupConnection(watcher.conn);
+ }
+ }
+
+ /**
+ * Part of JSActor API
+ * https://searchfox.org/mozilla-central/rev/d9f92154813fbd4a528453c33886dc3a74f27abb/dom/chrome-webidl/JSActor.webidl#41-42,52
+ *
+ * > The didDestroy method, if present, will be called after the ProcessActor is no
+ * > longer able to receive any more messages.
+ */
+ didDestroy() {
+ this._destroy();
+ }
+
+ /**
+ * Supported Queries
+ */
+
+ async sendPacket(packet, prefix) {
+ return this.sendAsyncMessage("DevToolsServiceWorkerParent:packet", {
+ packet,
+ prefix,
+ });
+ }
+
+ /**
+ * JsWindowActor API
+ */
+
+ async sendQuery(msg, args) {
+ try {
+ const res = await super.sendQuery(msg, args);
+ return res;
+ } catch (e) {
+ console.error(
+ "Failed to sendQuery in DevToolsServiceWorkerParent",
+ msg,
+ e
+ );
+ throw e;
+ }
+ }
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "DevToolsServiceWorkerChild:serviceWorkerTargetAvailable":
+ return this.serviceWorkerTargetAvailable(message.data);
+ case "DevToolsServiceWorkerChild:serviceWorkerTargetDestroyed":
+ return this.serviceWorkerTargetDestroyed(message.data);
+ case "DevToolsServiceWorkerChild:packet":
+ return this.emit("packet-received", message);
+ default:
+ throw new Error(
+ "Unsupported message in DevToolsServiceWorkerParent: " + message.name
+ );
+ }
+ }
+}
diff --git a/devtools/server/connectors/process-actor/moz.build b/devtools/server/connectors/process-actor/moz.build
new file mode 100644
index 0000000000..63f768bd3c
--- /dev/null
+++ b/devtools/server/connectors/process-actor/moz.build
@@ -0,0 +1,10 @@
+# -*- 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(
+ "DevToolsServiceWorkerChild.sys.mjs",
+ "DevToolsServiceWorkerParent.sys.mjs",
+)
diff --git a/devtools/server/connectors/worker-connector.js b/devtools/server/connectors/worker-connector.js
new file mode 100644
index 0000000000..90d55d7a69
--- /dev/null
+++ b/devtools/server/connectors/worker-connector.js
@@ -0,0 +1,208 @@
+/* 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";
+
+var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+
+loader.lazyRequireGetter(
+ this,
+ "MainThreadWorkerDebuggerTransport",
+ "resource://devtools/shared/transport/worker-transport.js",
+ true
+);
+
+/**
+ * Start a DevTools server in a worker and add it as a child server for a given active connection.
+ *
+ * @params {DevToolsConnection} connection
+ * @params {WorkerDebugger} dbg: The WorkerDebugger we want to create a target actor for.
+ * @params {String} forwardingPrefix: The prefix that will be used to forward messages
+ * to the DevToolsServer on the worker thread.
+ * @params {Object} options: An option object that will be passed with the "connect" packet.
+ * @params {Object} options.sessionData: The sessionData object that will be passed to the
+ * worker target actor.
+ */
+function connectToWorker(connection, dbg, forwardingPrefix, options) {
+ return new Promise((resolve, reject) => {
+ if (!DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ reject("closed");
+ return;
+ }
+
+ // Step 1: Ensure the worker debugger is initialized.
+ if (!dbg.isInitialized) {
+ dbg.initialize("resource://devtools/server/startup/worker.js");
+
+ // Create a listener for rpc requests from the worker debugger. Only do
+ // this once, when the worker debugger is first initialized, rather than
+ // for each connection.
+ const listener = {
+ onClose: () => {
+ dbg.removeListener(listener);
+ },
+
+ onMessage: message => {
+ message = JSON.parse(message);
+ if (message.type !== "rpc") {
+ if (message.type == "worker-thread-attached") {
+ // The thread actor has finished attaching and can hit installed
+ // breakpoints. Allow content to begin executing in the worker.
+ dbg.setDebuggerReady(true);
+ }
+ return;
+ }
+
+ Promise.resolve()
+ .then(() => {
+ const method = {
+ fetch: DevToolsUtils.fetch,
+ }[message.method];
+ if (!method) {
+ throw Error("Unknown method: " + message.method);
+ }
+
+ return method.apply(undefined, message.params);
+ })
+ .then(
+ value => {
+ dbg.postMessage(
+ JSON.stringify({
+ type: "rpc",
+ result: value,
+ error: null,
+ id: message.id,
+ })
+ );
+ },
+ reason => {
+ dbg.postMessage(
+ JSON.stringify({
+ type: "rpc",
+ result: null,
+ error: reason,
+ id: message.id,
+ })
+ );
+ }
+ );
+ },
+ };
+
+ dbg.addListener(listener);
+ }
+
+ if (!DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ reject("closed");
+ return;
+ }
+
+ // WorkerDebugger.url isn't always an absolute URL.
+ // Use the related document URL in order to make it absolute.
+ const absoluteURL = dbg.window?.location?.href
+ ? new URL(dbg.url, dbg.window.location.href).href
+ : dbg.url;
+
+ // Step 2: Send a connect request to the worker debugger.
+ dbg.postMessage(
+ JSON.stringify({
+ type: "connect",
+ forwardingPrefix,
+ options,
+ workerDebuggerData: {
+ id: dbg.id,
+ type: dbg.type,
+ url: absoluteURL,
+ // We don't have access to Services.prefs in Worker thread, so pass its value
+ // from here.
+ workerConsoleApiMessagesDispatchedToMainThread:
+ Services.prefs.getBoolPref(
+ "dom.worker.console.dispatch_events_to_main_thread"
+ ),
+ },
+ })
+ );
+
+ // Steps 3-5 are performed on the worker thread (see worker.js).
+
+ // Step 6: Wait for a connection response from the worker debugger.
+ const listener = {
+ onClose: () => {
+ dbg.removeListener(listener);
+
+ reject("closed");
+ },
+
+ onMessage: message => {
+ message = JSON.parse(message);
+ if (
+ message.type !== "connected" ||
+ message.forwardingPrefix !== forwardingPrefix
+ ) {
+ return;
+ }
+
+ // The initial connection message has been received, don't
+ // need to listen any longer
+ dbg.removeListener(listener);
+
+ // Step 7: Create a transport for the connection to the worker.
+ const transport = new MainThreadWorkerDebuggerTransport(
+ dbg,
+ forwardingPrefix
+ );
+ transport.ready();
+ transport.hooks = {
+ onTransportClosed: () => {
+ if (DevToolsUtils.isWorkerDebuggerAlive(dbg)) {
+ // If the worker happens to be shutting down while we are trying
+ // to close the connection, there is a small interval during
+ // which no more runnables can be dispatched to the worker, but
+ // the worker debugger has not yet been closed. In that case,
+ // the call to postMessage below will fail. The onTransportClosed hook on
+ // DebuggerTransport is not supposed to throw exceptions, so we
+ // need to make sure to catch these early.
+ try {
+ dbg.postMessage(
+ JSON.stringify({
+ type: "disconnect",
+ forwardingPrefix,
+ })
+ );
+ } catch (e) {
+ // We can safely ignore these exceptions. The only time the
+ // call to postMessage can fail is if the worker is either
+ // shutting down, or has finished shutting down. In both
+ // cases, there is nothing to clean up, so we don't care
+ // whether this message arrives or not.
+ }
+ }
+
+ connection.cancelForwarding(forwardingPrefix);
+ },
+
+ onPacket: packet => {
+ // Ensure that any packets received from the server on the worker
+ // thread are forwarded to the client on the main thread, as if
+ // they had been sent by the server on the main thread.
+ connection.send(packet);
+ },
+ };
+
+ // Ensure that any packets received from the client on the main thread
+ // to actors on the worker thread are forwarded to the server on the
+ // worker thread.
+ connection.setForwarding(forwardingPrefix, transport);
+
+ resolve({
+ workerTargetForm: message.workerTargetForm,
+ transport,
+ });
+ },
+ };
+ dbg.addListener(listener);
+ });
+}
+
+exports.connectToWorker = connectToWorker;
diff --git a/devtools/server/devtools-server-connection.js b/devtools/server/devtools-server-connection.js
new file mode 100644
index 0000000000..53d977a8fe
--- /dev/null
+++ b/devtools/server/devtools-server-connection.js
@@ -0,0 +1,543 @@
+/* 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";
+
+var { Pool } = require("resource://devtools/shared/protocol.js");
+var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+var { dumpn } = DevToolsUtils;
+
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "DevToolsServer",
+ "resource://devtools/server/devtools-server.js",
+ true
+);
+
+/**
+ * Creates a DevToolsServerConnection.
+ *
+ * Represents a connection to this debugging global from a client.
+ * Manages a set of actors and actor pools, allocates actor ids, and
+ * handles incoming requests.
+ *
+ * @param prefix string
+ * All actor IDs created by this connection should be prefixed
+ * with prefix.
+ * @param transport transport
+ * Packet transport for the debugging protocol.
+ * @param socketListener SocketListener
+ * SocketListener which accepted the transport.
+ * If this is null, the transport is not that was accepted by SocketListener.
+ */
+function DevToolsServerConnection(prefix, transport, socketListener) {
+ this._prefix = prefix;
+ this._transport = transport;
+ this._transport.hooks = this;
+ this._nextID = 1;
+ this._socketListener = socketListener;
+
+ this._actorPool = new Pool(this, "server-connection");
+ this._extraPools = [this._actorPool];
+
+ // Responses to a given actor must be returned the the client
+ // in the same order as the requests that they're replying to, but
+ // Implementations might finish serving requests in a different
+ // order. To keep things in order we generate a promise for each
+ // request, chained to the promise for the request before it.
+ // This map stores the latest request promise in the chain, keyed
+ // by an actor ID string.
+ this._actorResponses = new Map();
+
+ /*
+ * We can forward packets to other servers, if the actors on that server
+ * all use a distinct prefix on their names. This is a map from prefixes
+ * to transports: it maps a prefix P to a transport T if T conveys
+ * packets to the server whose actors' names all begin with P + "/".
+ */
+ this._forwardingPrefixes = new Map();
+
+ EventEmitter.decorate(this);
+}
+exports.DevToolsServerConnection = DevToolsServerConnection;
+
+DevToolsServerConnection.prototype = {
+ _prefix: null,
+ get prefix() {
+ return this._prefix;
+ },
+
+ /**
+ * For a DevToolsServerConnection used in content processes,
+ * returns the prefix of the connection it originates from, from the parent process.
+ */
+ get parentPrefix() {
+ this.prefix.replace(/child\d+\//, "");
+ },
+
+ _transport: null,
+ get transport() {
+ return this._transport;
+ },
+
+ close(options) {
+ if (this._transport) {
+ this._transport.close(options);
+ }
+ },
+
+ send(packet) {
+ this.transport.send(packet);
+ },
+
+ /**
+ * Used when sending a bulk reply from an actor.
+ * @see DebuggerTransport.prototype.startBulkSend
+ */
+ startBulkSend(header) {
+ return this.transport.startBulkSend(header);
+ },
+
+ allocID(prefix) {
+ return this.prefix + (prefix || "") + this._nextID++;
+ },
+
+ /**
+ * Add a map of actor IDs to the connection.
+ */
+ addActorPool(actorPool) {
+ this._extraPools.push(actorPool);
+ },
+
+ /**
+ * Remove a previously-added pool of actors to the connection.
+ *
+ * @param Pool actorPool
+ * The Pool instance you want to remove.
+ */
+ removeActorPool(actorPool) {
+ // When a connection is closed, it removes each of its actor pools. When an
+ // actor pool is removed, it calls the destroy method on each of its
+ // actors. Some actors, such as ThreadActor, manage their own actor pools.
+ // When the destroy method is called on these actors, they manually
+ // remove their actor pools. Consequently, this method is reentrant.
+ //
+ // In addition, some actors, such as ThreadActor, perform asynchronous work
+ // (in the case of ThreadActor, because they need to resume), before they
+ // remove each of their actor pools. Since we don't wait for this work to
+ // be completed, we can end up in this function recursively after the
+ // connection already set this._extraPools to null.
+ //
+ // This is a bug: if the destroy method can perform asynchronous work,
+ // then we should wait for that work to be completed before setting this.
+ // _extraPools to null. As a temporary solution, it should be acceptable
+ // to just return early (if this._extraPools has been set to null, all
+ // actors pools for this connection should already have been removed).
+ if (this._extraPools === null) {
+ return;
+ }
+ const index = this._extraPools.lastIndexOf(actorPool);
+ if (index > -1) {
+ this._extraPools.splice(index, 1);
+ }
+ },
+
+ /**
+ * Add an actor to the default actor pool for this connection.
+ */
+ addActor(actor) {
+ this._actorPool.manage(actor);
+ },
+
+ /**
+ * Remove an actor to the default actor pool for this connection.
+ */
+ removeActor(actor) {
+ this._actorPool.unmanage(actor);
+ },
+
+ /**
+ * Match the api expected by the protocol library.
+ */
+ unmanage(actor) {
+ return this.removeActor(actor);
+ },
+
+ /**
+ * Look up an actor implementation for an actorID. Will search
+ * all the actor pools registered with the connection.
+ *
+ * @param actorID string
+ * Actor ID to look up.
+ */
+ getActor(actorID) {
+ const pool = this.poolFor(actorID);
+ if (pool) {
+ return pool.getActorByID(actorID);
+ }
+
+ if (actorID === "root") {
+ return this.rootActor;
+ }
+
+ return null;
+ },
+
+ _getOrCreateActor(actorID) {
+ try {
+ const actor = this.getActor(actorID);
+ if (!actor) {
+ this.transport.send({
+ from: actorID ? actorID : "root",
+ error: "noSuchActor",
+ message: "No such actor for ID: " + actorID,
+ });
+ return null;
+ }
+
+ if (typeof actor !== "object") {
+ // Pools should now contain only actor instances (i.e. objects)
+ throw new Error(
+ `Unexpected actor constructor/function in Pool for actorID "${actorID}".`
+ );
+ }
+
+ return actor;
+ } catch (error) {
+ const prefix = `Error occurred while creating actor' ${actorID}`;
+ this.transport.send(this._unknownError(actorID, prefix, error));
+ }
+ return null;
+ },
+
+ poolFor(actorID) {
+ for (const pool of this._extraPools) {
+ if (pool.has(actorID)) {
+ return pool;
+ }
+ }
+ return null;
+ },
+
+ _unknownError(from, prefix, error) {
+ const errorString = prefix + ": " + DevToolsUtils.safeErrorString(error);
+ // On worker threads we don't have access to Cu.
+ if (!isWorker) {
+ console.error(errorString);
+ }
+ dumpn(errorString);
+ return {
+ from,
+ error: "unknownError",
+ message: errorString,
+ };
+ },
+
+ _queueResponse(from, type, responseOrPromise) {
+ const pendingResponse =
+ this._actorResponses.get(from) || Promise.resolve(null);
+ const responsePromise = pendingResponse
+ .then(() => {
+ return responseOrPromise;
+ })
+ .then(response => {
+ if (!this.transport) {
+ throw new Error(
+ `Connection closed, pending response from '${from}', ` +
+ `type '${type}' failed`
+ );
+ }
+
+ if (!response.from) {
+ response.from = from;
+ }
+
+ this.transport.send(response);
+ })
+ .catch(error => {
+ if (!this.transport) {
+ throw new Error(
+ `Connection closed, pending error from '${from}', ` +
+ `type '${type}' failed`
+ );
+ }
+
+ const prefix = `error occurred while queuing response for '${type}'`;
+ this.transport.send(this._unknownError(from, prefix, error));
+ });
+
+ this._actorResponses.set(from, responsePromise);
+ },
+
+ /**
+ * This function returns whether the connection was accepted by passed SocketListener.
+ *
+ * @param {SocketListener} socketListener
+ * @return {Boolean} return true if this connection was accepted by socketListener,
+ * else returns false.
+ */
+ isAcceptedBy(socketListener) {
+ return this._socketListener === socketListener;
+ },
+
+ /* Forwarding packets to other transports based on actor name prefixes. */
+
+ /*
+ * Arrange to forward packets to another server. This is how we
+ * forward debugging connections to child processes.
+ *
+ * If we receive a packet for an actor whose name begins with |prefix|
+ * followed by '/', then we will forward that packet to |transport|.
+ *
+ * This overrides any prior forwarding for |prefix|.
+ *
+ * @param prefix string
+ * The actor name prefix, not including the '/'.
+ * @param transport object
+ * A packet transport to which we should forward packets to actors
+ * whose names begin with |(prefix + '/').|
+ */
+ setForwarding(prefix, transport) {
+ this._forwardingPrefixes.set(prefix, transport);
+ },
+
+ /*
+ * Stop forwarding messages to actors whose names begin with
+ * |prefix+'/'|. Such messages will now elicit 'noSuchActor' errors.
+ */
+ cancelForwarding(prefix) {
+ this._forwardingPrefixes.delete(prefix);
+
+ // Notify the client that forwarding in now cancelled for this prefix.
+ // There could be requests in progress that the client should abort rather leaving
+ // handing indefinitely.
+ if (this.rootActor) {
+ this.send(this.rootActor.forwardingCancelled(prefix));
+ }
+ },
+
+ sendActorEvent(actorID, eventName, event = {}) {
+ event.from = actorID;
+ event.type = eventName;
+ this.send(event);
+ },
+
+ // Transport hooks.
+
+ /**
+ * Called by DebuggerTransport to dispatch incoming packets as appropriate.
+ *
+ * @param packet object
+ * The incoming packet.
+ */
+ onPacket(packet) {
+ // If the actor's name begins with a prefix we've been asked to
+ // forward, do so.
+ //
+ // Note that the presence of a prefix alone doesn't indicate that
+ // forwarding is needed: in DevToolsServerConnection instances in child
+ // processes, every actor has a prefixed name.
+ if (this._forwardingPrefixes.size > 0) {
+ let to = packet.to;
+ let separator = to.lastIndexOf("/");
+ while (separator >= 0) {
+ to = to.substring(0, separator);
+ const forwardTo = this._forwardingPrefixes.get(
+ packet.to.substring(0, separator)
+ );
+ if (forwardTo) {
+ forwardTo.send(packet);
+ return;
+ }
+ separator = to.lastIndexOf("/");
+ }
+ }
+
+ const actor = this._getOrCreateActor(packet.to);
+ if (!actor) {
+ return;
+ }
+
+ let ret = null;
+
+ // handle "requestTypes" RDP request.
+ if (packet.type == "requestTypes") {
+ ret = {
+ from: actor.actorID,
+ requestTypes: Object.keys(actor.requestTypes),
+ };
+ } else if (actor.requestTypes?.[packet.type]) {
+ // Dispatch the request to the actor.
+ try {
+ this.currentPacket = packet;
+ ret = actor.requestTypes[packet.type].bind(actor)(packet, this);
+ } catch (error) {
+ // Support legacy errors from old actors such as thread actor which
+ // throw { error, message } objects.
+ let errorMessage = error;
+ if (error?.error && error?.message) {
+ errorMessage = `"(${error.error}) ${error.message}"`;
+ }
+
+ const prefix = `error occurred while processing '${packet.type}'`;
+ this.transport.send(
+ this._unknownError(actor.actorID, prefix, errorMessage)
+ );
+ } finally {
+ this.currentPacket = undefined;
+ }
+ } else {
+ ret = {
+ error: "unrecognizedPacketType",
+ message: `Actor ${actor.actorID} does not recognize the packet type '${packet.type}'`,
+ };
+ }
+
+ // There will not be a return value if a bulk reply is sent.
+ if (ret) {
+ this._queueResponse(packet.to, packet.type, ret);
+ }
+ },
+
+ /**
+ * Called by the DebuggerTransport to dispatch incoming bulk packets as
+ * appropriate.
+ *
+ * @param packet object
+ * The incoming packet, which contains:
+ * * actor: Name of actor that will receive the packet
+ * * type: Name of actor's method that should be called on receipt
+ * * length: Size of the data to be read
+ * * stream: This input stream should only be used directly if you can
+ * ensure that you will read exactly |length| bytes and will
+ * not close the stream when reading is complete
+ * * done: If you use the stream directly (instead of |copyTo|
+ * below), you must signal completion by resolving /
+ * rejecting this deferred. If it's rejected, the transport
+ * will be closed. If an Error is supplied as a rejection
+ * value, it will be logged via |dumpn|. If you do use
+ * |copyTo|, resolving is taken care of for you when copying
+ * completes.
+ * * copyTo: A helper function for getting your data out of the stream
+ * that meets the stream handling requirements above, and has
+ * the following signature:
+ * @param output nsIAsyncOutputStream
+ * The stream to copy to.
+ * @return Promise
+ * The promise is resolved when copying completes or rejected
+ * if any (unexpected) errors occur.
+ * This object also emits "progress" events for each chunk
+ * that is copied. See stream-utils.js.
+ */
+ onBulkPacket(packet) {
+ const { actor: actorKey, type } = packet;
+
+ const actor = this._getOrCreateActor(actorKey);
+ if (!actor) {
+ return;
+ }
+
+ // Dispatch the request to the actor.
+ let ret;
+ if (actor.requestTypes?.[type]) {
+ try {
+ ret = actor.requestTypes[type].call(actor, packet);
+ } catch (error) {
+ const prefix = `error occurred while processing bulk packet '${type}'`;
+ this.transport.send(this._unknownError(actorKey, prefix, error));
+ packet.done.reject(error);
+ }
+ } else {
+ const message = `Actor ${actorKey} does not recognize the bulk packet type '${type}'`;
+ ret = { error: "unrecognizedPacketType", message };
+ packet.done.reject(new Error(message));
+ }
+
+ // If there is a JSON response, queue it for sending back to the client.
+ if (ret) {
+ this._queueResponse(actorKey, type, ret);
+ }
+ },
+
+ /**
+ * Called by DebuggerTransport when the underlying stream is closed.
+ *
+ * @param status nsresult
+ * The status code that corresponds to the reason for closing
+ * the stream.
+ * @param {object} options
+ * @param {boolean} options.isModeSwitching
+ * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref
+ */
+ onTransportClosed(status, options) {
+ dumpn("Cleaning up connection.");
+ if (!this._actorPool) {
+ // Ignore this call if the connection is already closed.
+ return;
+ }
+ this._actorPool = null;
+
+ this.emit("closed", status, this.prefix);
+
+ // Use filter in order to create a copy of the extraPools array,
+ // which might be modified by removeActorPool calls.
+ // The isTopLevel check ensures that the pools retrieved here will not be
+ // destroyed by another Pool::destroy. Non top-level pools will be destroyed
+ // by the recursive Pool::destroy mechanism.
+ // See test_connection_closes_all_pools.js for practical examples of Pool
+ // hierarchies.
+ const topLevelPools = this._extraPools.filter(p => p.isTopPool());
+ topLevelPools.forEach(p => p.destroy(options));
+
+ this._extraPools = null;
+
+ this.rootActor = null;
+ this._transport = null;
+ DevToolsServer._connectionClosed(this);
+ },
+
+ dumpPool(pool, output = [], dumpedPools) {
+ const actorIds = [];
+ const children = [];
+
+ if (dumpedPools.has(pool)) {
+ return;
+ }
+ dumpedPools.add(pool);
+
+ // TRUE if the pool is a Pool
+ if (!pool.__poolMap) {
+ return;
+ }
+
+ for (const actor of pool.poolChildren()) {
+ children.push(actor);
+ actorIds.push(actor.actorID);
+ }
+ const label = pool.label || pool.actorID;
+
+ output.push([label, actorIds]);
+ dump(`- ${label}: ${JSON.stringify(actorIds)}\n`);
+ children.forEach(childPool =>
+ this.dumpPool(childPool, output, dumpedPools)
+ );
+ },
+
+ /*
+ * Debugging helper for inspecting the state of the actor pools.
+ */
+ dumpPools() {
+ const output = [];
+ const dumpedPools = new Set();
+
+ this._extraPools.forEach(pool => this.dumpPool(pool, output, dumpedPools));
+
+ return output;
+ },
+};
diff --git a/devtools/server/devtools-server.js b/devtools/server/devtools-server.js
new file mode 100644
index 0000000000..e1dd7994d1
--- /dev/null
+++ b/devtools/server/devtools-server.js
@@ -0,0 +1,513 @@
+/* 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";
+
+var {
+ ActorRegistry,
+} = require("resource://devtools/server/actors/utils/actor-registry.js");
+var DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+var { dumpn } = DevToolsUtils;
+
+loader.lazyRequireGetter(
+ this,
+ "DevToolsServerConnection",
+ "resource://devtools/server/devtools-server-connection.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "Authentication",
+ "resource://devtools/shared/security/auth.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "LocalDebuggerTransport",
+ "resource://devtools/shared/transport/local-transport.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ChildDebuggerTransport",
+ "resource://devtools/shared/transport/child-transport.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "JsWindowActorTransport",
+ "resource://devtools/shared/transport/js-window-actor-transport.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "WorkerThreadWorkerDebuggerTransport",
+ "resource://devtools/shared/transport/worker-transport.js",
+ true
+);
+
+const CONTENT_PROCESS_SERVER_STARTUP_SCRIPT =
+ "resource://devtools/server/startup/content-process.js";
+
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+
+/**
+ * DevToolsServer is a singleton that has several responsibilities. It will
+ * register the DevTools server actors that are relevant to the context.
+ * It can also create other DevToolsServer, that will live in the same
+ * environment as the debugged target (content page, worker...).
+ *
+ * For instance a regular Toolbox will be linked to DevToolsClient connected to
+ * a DevToolsServer running in the same process as the Toolbox (main process).
+ * But another DevToolsServer will be created in the same process as the page
+ * targeted by the Toolbox.
+ *
+ * Despite being a singleton, the DevToolsServer still has a lifecycle and a
+ * state. When a consumer needs to spawn a DevToolsServer, the init() method
+ * should be called. Then you should either call registerAllActors or
+ * registerActors to setup the server.
+ * When the server is no longer needed, destroy() should be called.
+ *
+ */
+var DevToolsServer = {
+ _listeners: [],
+ _initialized: false,
+ // Map of global actor names to actor constructors.
+ globalActorFactories: {},
+ // Map of target-scoped actor names to actor constructors.
+ targetScopedActorFactories: {},
+
+ LONG_STRING_LENGTH: 10000,
+ LONG_STRING_INITIAL_LENGTH: 1000,
+ LONG_STRING_READ_LENGTH: 65 * 1024,
+
+ /**
+ * The windowtype of the chrome window to use for actors that use the global
+ * window (i.e the global style editor). Set this to your main window type,
+ * for example "navigator:browser".
+ */
+ chromeWindowType: "navigator:browser",
+
+ /**
+ * Allow debugging chrome of (parent or child) processes.
+ */
+ allowChromeProcess: false,
+
+ /**
+ * Flag used to check if the server can be destroyed when all connections have been
+ * removed. Firefox on Android runs a single shared DevToolsServer, and should not be
+ * closed even if no client is connected.
+ */
+ keepAlive: false,
+
+ /**
+ * We run a special server in child process whose main actor is an instance
+ * of WindowGlobalTargetActor, but that isn't a root actor. Instead there is no root
+ * actor registered on DevToolsServer.
+ */
+ get rootlessServer() {
+ return !this.createRootActor;
+ },
+
+ /**
+ * Initialize the devtools server.
+ */
+ init() {
+ if (this.initialized) {
+ return;
+ }
+
+ this._connections = {};
+ ActorRegistry.init(this._connections);
+ this._nextConnID = 0;
+
+ this._initialized = true;
+ this._onSocketListenerAccepted = this._onSocketListenerAccepted.bind(this);
+
+ if (!isWorker) {
+ // Mochitests watch this observable in order to register the custom actor
+ // highlighter-test-actor.js.
+ // Services.obs is not available in workers.
+ const subject = { wrappedJSObject: ActorRegistry };
+ Services.obs.notifyObservers(subject, "devtools-server-initialized");
+ }
+ },
+
+ get protocol() {
+ return require("resource://devtools/shared/protocol.js");
+ },
+
+ get initialized() {
+ return this._initialized;
+ },
+
+ hasConnection() {
+ return this._connections && !!Object.keys(this._connections).length;
+ },
+
+ hasConnectionForPrefix(prefix) {
+ return this._connections && !!this._connections[prefix + "/"];
+ },
+ /**
+ * Performs cleanup tasks before shutting down the devtools server. Such tasks
+ * include clearing any actor constructors added at runtime. This method
+ * should be called whenever a devtools server is no longer useful, to avoid
+ * memory leaks. After this method returns, the devtools server must be
+ * initialized again before use.
+ */
+ destroy() {
+ if (!this._initialized) {
+ return;
+ }
+ this._initialized = false;
+
+ for (const connection of Object.values(this._connections)) {
+ connection.close();
+ }
+
+ ActorRegistry.destroy();
+ this.closeAllSocketListeners();
+
+ // Unregister all listeners
+ this.off("connectionchange");
+
+ dumpn("DevTools server is shut down.");
+ },
+
+ /**
+ * Raises an exception if the server has not been properly initialized.
+ */
+ _checkInit() {
+ if (!this._initialized) {
+ throw new Error("DevToolsServer has not been initialized.");
+ }
+
+ if (!this.rootlessServer && !this.createRootActor) {
+ throw new Error(
+ "Use DevToolsServer.setRootActor() to add a root actor " +
+ "implementation."
+ );
+ }
+ },
+
+ /**
+ * Register different type of actors. Only register the one that are not already
+ * registered.
+ *
+ * @param root boolean
+ * Registers the root actor from webbrowser module, which is used to
+ * connect to and fetch any other actor.
+ * @param browser boolean
+ * Registers all the parent process actors useful for debugging the
+ * runtime itself, like preferences and addons actors.
+ * @param target boolean
+ * Registers all the target-scoped actors like console, script, etc.
+ * for debugging a target context.
+ */
+ registerActors({ root, browser, target }) {
+ if (browser) {
+ ActorRegistry.addBrowserActors();
+ }
+
+ if (root) {
+ const {
+ createRootActor,
+ } = require("resource://devtools/server/actors/webbrowser.js");
+ this.setRootActor(createRootActor);
+ }
+
+ if (target) {
+ ActorRegistry.addTargetScopedActors();
+ }
+ },
+
+ /**
+ * Register all possible actors for this DevToolsServer.
+ */
+ registerAllActors() {
+ this.registerActors({ root: true, browser: true, target: true });
+ },
+
+ get listeningSockets() {
+ return this._listeners.length;
+ },
+
+ /**
+ * Add a SocketListener instance to the server's set of active
+ * SocketListeners. This is called by a SocketListener after it is opened.
+ */
+ addSocketListener(listener) {
+ if (!Services.prefs.getBoolPref("devtools.debugger.remote-enabled")) {
+ throw new Error("Can't add a SocketListener, remote debugging disabled");
+ }
+ this._checkInit();
+
+ listener.on("accepted", this._onSocketListenerAccepted);
+ this._listeners.push(listener);
+ },
+
+ /**
+ * Remove a SocketListener instance from the server's set of active
+ * SocketListeners. This is called by a SocketListener after it is closed.
+ */
+ removeSocketListener(listener) {
+ // Remove connections that were accepted in the listener.
+ for (const connID of Object.getOwnPropertyNames(this._connections)) {
+ const connection = this._connections[connID];
+ if (connection.isAcceptedBy(listener)) {
+ connection.close();
+ }
+ }
+
+ this._listeners = this._listeners.filter(l => l !== listener);
+ listener.off("accepted", this._onSocketListenerAccepted);
+ },
+
+ /**
+ * Closes and forgets all previously opened listeners.
+ *
+ * @return boolean
+ * Whether any listeners were actually closed.
+ */
+ closeAllSocketListeners() {
+ if (!this.listeningSockets) {
+ return false;
+ }
+
+ for (const listener of this._listeners) {
+ listener.close();
+ }
+
+ return true;
+ },
+
+ _onSocketListenerAccepted(transport, listener) {
+ this._onConnection(transport, null, false, listener);
+ },
+
+ /**
+ * Creates a new connection to the local debugger speaking over a fake
+ * transport. This connection results in straightforward calls to the onPacket
+ * handlers of each side.
+ *
+ * @param prefix string [optional]
+ * If given, all actors in this connection will have names starting
+ * with |prefix + '/'|.
+ * @returns a client-side DebuggerTransport for communicating with
+ * the newly-created connection.
+ */
+ connectPipe(prefix) {
+ this._checkInit();
+
+ const serverTransport = new LocalDebuggerTransport();
+ const clientTransport = new LocalDebuggerTransport(serverTransport);
+ serverTransport.other = clientTransport;
+ const connection = this._onConnection(serverTransport, prefix);
+
+ // I'm putting this here because I trust you.
+ //
+ // There are times, when using a local connection, when you're going
+ // to be tempted to just get direct access to the server. Resist that
+ // temptation! If you succumb to that temptation, you will make the
+ // fine developers that work on Fennec and Firefox OS sad. They're
+ // professionals, they'll try to act like they understand, but deep
+ // down you'll know that you hurt them.
+ //
+ // This reference allows you to give in to that temptation. There are
+ // times this makes sense: tests, for example, and while porting a
+ // previously local-only codebase to the remote protocol.
+ //
+ // But every time you use this, you will feel the shame of having
+ // used a property that starts with a '_'.
+ clientTransport._serverConnection = connection;
+
+ return clientTransport;
+ },
+
+ /**
+ * In a content child process, create a new connection that exchanges
+ * nsIMessageSender messages with our parent process.
+ *
+ * @param prefix
+ * The prefix we should use in our nsIMessageSender message names and
+ * actor names. This connection will use messages named
+ * "debug:<prefix>:packet", and all its actors will have names
+ * beginning with "<prefix>/".
+ */
+ connectToParent(prefix, scopeOrManager) {
+ this._checkInit();
+
+ const transport = isWorker
+ ? new WorkerThreadWorkerDebuggerTransport(scopeOrManager, prefix)
+ : new ChildDebuggerTransport(scopeOrManager, prefix);
+
+ return this._onConnection(transport, prefix, true);
+ },
+
+ connectToParentWindowActor(jsWindowChildActor, forwardingPrefix) {
+ this._checkInit();
+ const transport = new JsWindowActorTransport(
+ jsWindowChildActor,
+ forwardingPrefix
+ );
+
+ return this._onConnection(transport, forwardingPrefix, true);
+ },
+
+ /**
+ * Check if the server is running in the child process.
+ */
+ get isInChildProcess() {
+ return (
+ Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT
+ );
+ },
+
+ /**
+ * Create a new debugger connection for the given transport. Called after
+ * connectPipe(), from connectToParent, or from an incoming socket
+ * connection handler.
+ *
+ * If present, |forwardingPrefix| is a forwarding prefix that a parent
+ * server is using to recognizes messages intended for this server. Ensure
+ * that all our actors have names beginning with |forwardingPrefix + '/'|.
+ * In particular, the root actor's name will be |forwardingPrefix + '/root'|.
+ */
+ _onConnection(
+ transport,
+ forwardingPrefix,
+ noRootActor = false,
+ socketListener = null
+ ) {
+ let connID;
+ if (forwardingPrefix) {
+ connID = forwardingPrefix + "/";
+ } else {
+ // Multiple servers can be started at the same time, and when that's the
+ // case, they are loaded in separate devtools loaders.
+ // So, use the current loader ID to prefix the connection ID and make it
+ // unique.
+ connID = "server" + loader.id + ".conn" + this._nextConnID++ + ".";
+ }
+
+ // Notify the platform code that DevTools is running in the current process
+ // when we are wiring the very first connection
+ if (!this.hasConnection()) {
+ ChromeUtils.notifyDevToolsOpened();
+ }
+
+ const conn = new DevToolsServerConnection(
+ connID,
+ transport,
+ socketListener
+ );
+ this._connections[connID] = conn;
+
+ // Create a root actor for the connection and send the hello packet.
+ if (!noRootActor) {
+ conn.rootActor = this.createRootActor(conn);
+ if (forwardingPrefix) {
+ conn.rootActor.actorID = forwardingPrefix + "/root";
+ } else {
+ conn.rootActor.actorID = "root";
+ }
+ conn.addActor(conn.rootActor);
+ transport.send(conn.rootActor.sayHello());
+ }
+ transport.ready();
+
+ this.emit("connectionchange", "opened", conn);
+ return conn;
+ },
+
+ /**
+ * Remove the connection from the debugging server.
+ */
+ _connectionClosed(connection) {
+ delete this._connections[connection.prefix];
+ this.emit("connectionchange", "closed", connection);
+
+ const hasConnection = this.hasConnection();
+
+ // Notify the platform code that we stopped running DevTools code in the current process
+ if (!hasConnection) {
+ ChromeUtils.notifyDevToolsClosed();
+ }
+
+ // If keepAlive isn't explicitely set to true, destroy the server once its
+ // last connection closes. Multiple JSWindowActor may use the same DevToolsServer
+ // and in this case, let the server destroy itself once the last connection closes.
+ // Otherwise we set keepAlive to true when starting a listening server, receiving
+ // client connections. Typically when running server on phones, or on desktop
+ // via `--start-debugger-server`.
+ if (hasConnection || this.keepAlive) {
+ return;
+ }
+
+ this.destroy();
+ },
+
+ // DevToolsServer extension API.
+
+ setRootActor(actorFactory) {
+ this.createRootActor = actorFactory;
+ },
+
+ /**
+ * Called when DevTools are unloaded to remove the contend process server startup script
+ * for the list of scripts loaded for each new content process. Will also remove message
+ * listeners from already loaded scripts.
+ */
+ removeContentServerScript() {
+ Services.ppmm.removeDelayedProcessScript(
+ CONTENT_PROCESS_SERVER_STARTUP_SCRIPT
+ );
+ try {
+ Services.ppmm.broadcastAsyncMessage("debug:close-content-server");
+ } catch (e) {
+ // Nothing to do
+ }
+ },
+
+ /**
+ * Searches all active connections for an actor matching an ID.
+ *
+ * ⚠ TO BE USED ONLY FROM SERVER CODE OR TESTING ONLY! ⚠`
+ *
+ * This is helpful for some tests which depend on reaching into the server to check some
+ * properties of an actor, and it is also used by the actors related to the
+ * DevTools WebExtensions API to be able to interact with the actors created for the
+ * panels natively provided by the DevTools Toolbox.
+ */
+ searchAllConnectionsForActor(actorID) {
+ // NOTE: the actor IDs are generated with the following format:
+ //
+ // `server${loaderID}.conn${ConnectionID}${ActorPrefix}${ActorID}`
+ //
+ // as an optimization we can come up with a regexp to query only
+ // the right connection via its id.
+ for (const connID of Object.getOwnPropertyNames(this._connections)) {
+ const actor = this._connections[connID].getActor(actorID);
+ if (actor) {
+ return actor;
+ }
+ }
+ return null;
+ },
+};
+
+// Expose these to save callers the trouble of importing DebuggerSocket
+DevToolsUtils.defineLazyGetter(DevToolsServer, "Authenticators", () => {
+ return Authentication.Authenticators;
+});
+DevToolsUtils.defineLazyGetter(DevToolsServer, "AuthenticationResult", () => {
+ return Authentication.AuthenticationResult;
+});
+
+EventEmitter.decorate(DevToolsServer);
+
+exports.DevToolsServer = DevToolsServer;
diff --git a/devtools/server/jar.mn b/devtools/server/jar.mn
new file mode 100644
index 0000000000..73f30ee4e4
--- /dev/null
+++ b/devtools/server/jar.mn
@@ -0,0 +1,8 @@
+#filter substitution
+# 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/.
+
+[localization] @AB_CD@.jar:
+ # This is a workaround to ship a fluent file not visible to localizers for experimental features.
+ devtools/server/actors/webconsole/commands/experimental-commands.ftl (actors/webconsole/commands/experimental-commands.ftl)
diff --git a/devtools/server/moz.build b/devtools/server/moz.build
new file mode 100644
index 0000000000..9f33d9822b
--- /dev/null
+++ b/devtools/server/moz.build
@@ -0,0 +1,32 @@
+# -*- 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/.
+
+include("../templates.mozbuild")
+
+DIRS += [
+ "actors",
+ "connectors",
+ "performance",
+ "socket",
+ "startup",
+ "tracer",
+]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"]
+
+MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"]
+XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"]
+
+DevToolsModules(
+ "devtools-server-connection.js",
+ "devtools-server.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "General")
diff --git a/devtools/server/performance/memory.js b/devtools/server/performance/memory.js
new file mode 100644
index 0000000000..c983a742ec
--- /dev/null
+++ b/devtools/server/performance/memory.js
@@ -0,0 +1,502 @@
+/* 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 {
+ reportException,
+} = require("resource://devtools/shared/DevToolsUtils.js");
+const { expectState } = require("resource://devtools/server/actors/common.js");
+
+loader.lazyRequireGetter(
+ this,
+ "EventEmitter",
+ "resource://devtools/shared/event-emitter.js"
+);
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+});
+loader.lazyRequireGetter(
+ this,
+ "StackFrameCache",
+ "resource://devtools/server/actors/utils/stack.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ParentProcessTargetActor",
+ "resource://devtools/server/actors/targets/parent-process.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "ContentProcessTargetActor",
+ "resource://devtools/server/actors/targets/content-process.js",
+ true
+);
+
+/**
+ * A class that returns memory data for a parent actor's window.
+ * Using a target-scoped actor with this instance will measure the memory footprint of its
+ * parent tab. Using a global-scoped actor instance however, will measure the memory
+ * footprint of the chrome window referenced by its root actor.
+ *
+ * To be consumed by actor's, like MemoryActor using this module to
+ * send information over RDP, and TimelineActor for using more light-weight
+ * utilities like GC events and measuring memory consumption.
+ */
+function Memory(parent, frameCache = new StackFrameCache()) {
+ EventEmitter.decorate(this);
+
+ this.parent = parent;
+ this._mgr = Cc["@mozilla.org/memory-reporter-manager;1"].getService(
+ Ci.nsIMemoryReporterManager
+ );
+ this.state = "detached";
+ this._dbg = null;
+ this._frameCache = frameCache;
+
+ this._onGarbageCollection = this._onGarbageCollection.bind(this);
+ this._emitAllocations = this._emitAllocations.bind(this);
+ this._onWindowReady = this._onWindowReady.bind(this);
+
+ EventEmitter.on(this.parent, "window-ready", this._onWindowReady);
+}
+
+Memory.prototype = {
+ destroy() {
+ EventEmitter.off(this.parent, "window-ready", this._onWindowReady);
+
+ this._mgr = null;
+ if (this.state === "attached") {
+ this.detach();
+ }
+ },
+
+ get dbg() {
+ if (!this._dbg) {
+ this._dbg = this.parent.makeDebugger();
+ }
+ return this._dbg;
+ },
+
+ /**
+ * Attach to this MemoryBridge.
+ *
+ * This attaches the MemoryBridge's Debugger instance so that you can start
+ * recording allocations or take a census of the heap. In addition, the
+ * MemoryBridge will start emitting GC events.
+ */
+ attach() {
+ // The actor may be attached by the Target via recordAllocation configuration
+ // or manually by the frontend.
+ if (this.state == "attached") {
+ return this.state;
+ }
+ this.dbg.addDebuggees();
+ this.dbg.memory.onGarbageCollection = this._onGarbageCollection.bind(this);
+ this.state = "attached";
+ return this.state;
+ },
+
+ /**
+ * Detach from this MemoryBridge.
+ */
+ detach: expectState(
+ "attached",
+ function () {
+ this._clearDebuggees();
+ this.dbg.disable();
+ this._dbg = null;
+ this.state = "detached";
+ return this.state;
+ },
+ "detaching from the debugger"
+ ),
+
+ /**
+ * Gets the current MemoryBridge attach/detach state.
+ */
+ getState() {
+ return this.state;
+ },
+
+ _clearDebuggees() {
+ if (this._dbg) {
+ if (this.isRecordingAllocations()) {
+ this.dbg.memory.drainAllocationsLog();
+ }
+ this._clearFrames();
+ this.dbg.removeAllDebuggees();
+ }
+ },
+
+ _clearFrames() {
+ if (this.isRecordingAllocations()) {
+ this._frameCache.clearFrames();
+ }
+ },
+
+ /**
+ * Handler for the parent actor's "window-ready" event.
+ */
+ _onWindowReady({ isTopLevel }) {
+ if (this.state == "attached") {
+ this._clearDebuggees();
+ if (isTopLevel && this.isRecordingAllocations()) {
+ this._frameCache.initFrames();
+ }
+ this.dbg.addDebuggees();
+ }
+ },
+
+ /**
+ * Returns a boolean indicating whether or not allocation
+ * sites are being tracked.
+ */
+ isRecordingAllocations() {
+ return this.dbg.memory.trackingAllocationSites;
+ },
+
+ /**
+ * Save a heap snapshot scoped to the current debuggees' portion of the heap
+ * graph.
+ *
+ * @param {Object|null} boundaries
+ *
+ * @returns {String} The snapshot id.
+ */
+ saveHeapSnapshot: expectState(
+ "attached",
+ function (boundaries = null) {
+ // If we are observing the whole process, then scope the snapshot
+ // accordingly. Otherwise, use the debugger's debuggees.
+ if (!boundaries) {
+ if (
+ this.parent instanceof ParentProcessTargetActor ||
+ this.parent instanceof ContentProcessTargetActor
+ ) {
+ boundaries = { runtime: true };
+ } else {
+ boundaries = { debugger: this.dbg };
+ }
+ }
+ return ChromeUtils.saveHeapSnapshotGetId(boundaries);
+ },
+ "saveHeapSnapshot"
+ ),
+
+ /**
+ * Take a census of the heap. See js/src/doc/Debugger/Debugger.Memory.md for
+ * more information.
+ */
+ takeCensus: expectState(
+ "attached",
+ function () {
+ return this.dbg.memory.takeCensus();
+ },
+ "taking census"
+ ),
+
+ /**
+ * Start recording allocation sites.
+ *
+ * @param {number} options.probability
+ * The probability we sample any given allocation when recording
+ * allocations. Must be between 0 and 1 -- defaults to 1.
+ * @param {number} options.maxLogLength
+ * The maximum number of allocation events to keep in the
+ * log. If new allocs occur while at capacity, oldest
+ * allocations are lost. Must fit in a 32 bit signed integer.
+ * @param {number} options.drainAllocationsTimeout
+ * A number in milliseconds of how often, at least, an `allocation`
+ * event gets emitted (and drained), and also emits and drains on every
+ * GC event, resetting the timer.
+ */
+ startRecordingAllocations: expectState(
+ "attached",
+ function (options = {}) {
+ if (this.isRecordingAllocations()) {
+ return this._getCurrentTime();
+ }
+
+ this._frameCache.initFrames();
+
+ this.dbg.memory.allocationSamplingProbability =
+ options.probability != null ? options.probability : 1.0;
+
+ this.drainAllocationsTimeoutTimer = options.drainAllocationsTimeout;
+
+ if (this.drainAllocationsTimeoutTimer != null) {
+ if (this._poller) {
+ this._poller.disarm();
+ }
+ this._poller = new lazy.DeferredTask(
+ this._emitAllocations,
+ this.drainAllocationsTimeoutTimer,
+ 0
+ );
+ this._poller.arm();
+ }
+
+ if (options.maxLogLength != null) {
+ this.dbg.memory.maxAllocationsLogLength = options.maxLogLength;
+ }
+ this.dbg.memory.trackingAllocationSites = true;
+
+ return this._getCurrentTime();
+ },
+ "starting recording allocations"
+ ),
+
+ /**
+ * Stop recording allocation sites.
+ */
+ stopRecordingAllocations: expectState(
+ "attached",
+ function () {
+ if (!this.isRecordingAllocations()) {
+ return this._getCurrentTime();
+ }
+ this.dbg.memory.trackingAllocationSites = false;
+ this._clearFrames();
+
+ if (this._poller) {
+ this._poller.disarm();
+ this._poller = null;
+ }
+
+ return this._getCurrentTime();
+ },
+ "stopping recording allocations"
+ ),
+
+ /**
+ * Return settings used in `startRecordingAllocations` for `probability`
+ * and `maxLogLength`. Currently only uses in tests.
+ */
+ getAllocationsSettings: expectState(
+ "attached",
+ function () {
+ return {
+ maxLogLength: this.dbg.memory.maxAllocationsLogLength,
+ probability: this.dbg.memory.allocationSamplingProbability,
+ };
+ },
+ "getting allocations settings"
+ ),
+
+ /**
+ * Get a list of the most recent allocations since the last time we got
+ * allocations, as well as a summary of all allocations since we've been
+ * recording.
+ *
+ * @returns Object
+ * An object of the form:
+ *
+ * {
+ * allocations: [<index into "frames" below>, ...],
+ * allocationsTimestamps: [
+ * <timestamp for allocations[0]>,
+ * <timestamp for allocations[1]>,
+ * ...
+ * ],
+ * allocationSizes: [
+ * <bytesize for allocations[0]>,
+ * <bytesize for allocations[1]>,
+ * ...
+ * ],
+ * frames: [
+ * {
+ * line: <line number for this frame>,
+ * column: <column number for this frame>,
+ * source: <filename string for this frame>,
+ * functionDisplayName:
+ * <this frame's inferred function name function or null>,
+ * parent: <index into "frames">
+ * },
+ * ...
+ * ],
+ * }
+ *
+ * The timestamps' unit is microseconds since the epoch.
+ *
+ * Subsequent `getAllocations` request within the same recording and
+ * tab navigation will always place the same stack frames at the same
+ * indices as previous `getAllocations` requests in the same
+ * recording. In other words, it is safe to use the index as a
+ * unique, persistent id for its frame.
+ *
+ * Additionally, the root node (null) is always at index 0.
+ *
+ * We use the indices into the "frames" array to avoid repeating the
+ * description of duplicate stack frames both when listing
+ * allocations, and when many stacks share the same tail of older
+ * frames. There shouldn't be any duplicates in the "frames" array,
+ * as that would defeat the purpose of this compression trick.
+ *
+ * In the future, we might want to split out a frame's "source" and
+ * "functionDisplayName" properties out the same way we have split
+ * frames out with the "frames" array. While this would further
+ * compress the size of the response packet, it would increase CPU
+ * usage to build the packet, and it should, of course, be guided by
+ * profiling and done only when necessary.
+ */
+ getAllocations: expectState(
+ "attached",
+ function () {
+ if (this.dbg.memory.allocationsLogOverflowed) {
+ // Since the last time we drained the allocations log, there have been
+ // more allocations than the log's capacity, and we lost some data. There
+ // isn't anything actionable we can do about this, but put a message in
+ // the browser console so we at least know that it occurred.
+ reportException(
+ "MemoryBridge.prototype.getAllocations",
+ "Warning: allocations log overflowed and lost some data."
+ );
+ }
+
+ const allocations = this.dbg.memory.drainAllocationsLog();
+ const packet = {
+ allocations: [],
+ allocationsTimestamps: [],
+ allocationSizes: [],
+ };
+ for (const { frame: stack, timestamp, size } of allocations) {
+ if (stack && Cu.isDeadWrapper(stack)) {
+ continue;
+ }
+
+ // Safe because SavedFrames are frozen/immutable.
+ const waived = Cu.waiveXrays(stack);
+
+ // Ensure that we have a form, size, and index for new allocations
+ // because we potentially haven't seen some or all of them yet. After this
+ // loop, we can rely on the fact that every frame we deal with already has
+ // its metadata stored.
+ const index = this._frameCache.addFrame(waived);
+
+ packet.allocations.push(index);
+ packet.allocationsTimestamps.push(timestamp);
+ packet.allocationSizes.push(size);
+ }
+
+ return this._frameCache.updateFramePacket(packet);
+ },
+ "getting allocations"
+ ),
+
+ /*
+ * Force a browser-wide GC.
+ */
+ forceGarbageCollection() {
+ for (let i = 0; i < 3; i++) {
+ Cu.forceGC();
+ }
+ },
+
+ /**
+ * Force an XPCOM cycle collection. For more information on XPCOM cycle
+ * collection, see
+ * https://developer.mozilla.org/en-US/docs/Interfacing_with_the_XPCOM_cycle_collector#What_the_cycle_collector_does
+ */
+ forceCycleCollection() {
+ Cu.forceCC();
+ },
+
+ /**
+ * A method that returns a detailed breakdown of the memory consumption of the
+ * associated window.
+ *
+ * @returns object
+ */
+ measure() {
+ const result = {};
+
+ const jsObjectsSize = {};
+ const jsStringsSize = {};
+ const jsOtherSize = {};
+ const domSize = {};
+ const styleSize = {};
+ const otherSize = {};
+ const totalSize = {};
+ const jsMilliseconds = {};
+ const nonJSMilliseconds = {};
+
+ try {
+ this._mgr.sizeOfTab(
+ this.parent.window,
+ jsObjectsSize,
+ jsStringsSize,
+ jsOtherSize,
+ domSize,
+ styleSize,
+ otherSize,
+ totalSize,
+ jsMilliseconds,
+ nonJSMilliseconds
+ );
+ result.total = totalSize.value;
+ result.domSize = domSize.value;
+ result.styleSize = styleSize.value;
+ result.jsObjectsSize = jsObjectsSize.value;
+ result.jsStringsSize = jsStringsSize.value;
+ result.jsOtherSize = jsOtherSize.value;
+ result.otherSize = otherSize.value;
+ result.jsMilliseconds = jsMilliseconds.value.toFixed(1);
+ result.nonJSMilliseconds = nonJSMilliseconds.value.toFixed(1);
+ } catch (e) {
+ reportException("MemoryBridge.prototype.measure", e);
+ }
+
+ return result;
+ },
+
+ residentUnique() {
+ return this._mgr.residentUnique;
+ },
+
+ /**
+ * Handler for GC events on the Debugger.Memory instance.
+ */
+ _onGarbageCollection(data) {
+ this.emit("garbage-collection", data);
+
+ // If `drainAllocationsTimeout` set, fire an allocations event with the drained log,
+ // which will restart the timer.
+ if (this._poller) {
+ this._poller.disarm();
+ this._emitAllocations();
+ }
+ },
+
+ /**
+ * Called on `drainAllocationsTimeoutTimer` interval if and only if set
+ * during `startRecordingAllocations`, or on a garbage collection event if
+ * drainAllocationsTimeout was set.
+ * Drains allocation log and emits as an event and restarts the timer.
+ */
+ _emitAllocations() {
+ this.emit("allocations", this.getAllocations());
+ this._poller.arm();
+ },
+
+ /**
+ * Accesses the docshell to return the current process time.
+ */
+ _getCurrentTime() {
+ const docShell = this.parent.isRootActor
+ ? this.parent.docShell
+ : this.parent.originalDocShell;
+ if (docShell) {
+ return docShell.now();
+ }
+ // When used from the ContentProcessTargetActor, parent has no docShell,
+ // so fallback to Cu.now
+ return Cu.now();
+ },
+};
+
+exports.Memory = Memory;
diff --git a/devtools/server/performance/moz.build b/devtools/server/performance/moz.build
new file mode 100644
index 0000000000..3524cb6205
--- /dev/null
+++ b/devtools/server/performance/moz.build
@@ -0,0 +1,12 @@
+# -*- 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(
+ "memory.js",
+)
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)")
diff --git a/devtools/server/socket/moz.build b/devtools/server/socket/moz.build
new file mode 100644
index 0000000000..1e0b5cf942
--- /dev/null
+++ b/devtools/server/socket/moz.build
@@ -0,0 +1,11 @@
+# -*- 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/.
+
+MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.toml"]
+
+DevToolsModules(
+ "websocket-server.js",
+)
diff --git a/devtools/server/socket/tests/chrome/chrome.toml b/devtools/server/socket/tests/chrome/chrome.toml
new file mode 100644
index 0000000000..2d53e5731d
--- /dev/null
+++ b/devtools/server/socket/tests/chrome/chrome.toml
@@ -0,0 +1,4 @@
+[DEFAULT]
+tags = "devtools"
+
+["test_websocket-server.html"]
diff --git a/devtools/server/socket/tests/chrome/test_websocket-server.html b/devtools/server/socket/tests/chrome/test_websocket-server.html
new file mode 100644
index 0000000000..b809aca0a5
--- /dev/null
+++ b/devtools/server/socket/tests/chrome/test_websocket-server.html
@@ -0,0 +1,88 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Mozilla Bug</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<script>
+"use strict";
+
+window.onload = function() {
+ const { require } = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ const WebSocketServer = require("devtools/server/socket/websocket-server");
+
+ const ServerSocket = Components.Constructor("@mozilla.org/network/server-socket;1",
+ "nsIServerSocket", "init");
+
+ add_task(async function() {
+ // Create a TCP server on auto-assigned port
+ const server = new ServerSocket(-1, true, -1);
+ ok(server, `Launched WebSocket server on port ${server.port}`);
+
+ let input, output;
+
+ server.asyncListen({
+ async onSocketAccepted(socket, transport) {
+ info("Accepted incoming connection");
+ input = transport.openInputStream(0, 0, 0);
+ output = transport.openOutputStream(0, 0, 0);
+
+ // Perform the WebSocket handshake
+ const webSocket = await WebSocketServer.accept(transport, input, output);
+
+ // Echo the received message back to the sender
+ webSocket.onmessage = ({ data }) => {
+ info("Server received message, echoing back");
+ webSocket.send(data);
+ };
+ },
+
+ onStopListening(socket, status) {
+ info(`Server stopped listening with status: ${status}`);
+ },
+ });
+
+ SimpleTest.registerCleanupFunction(() => {
+ server.close();
+ });
+
+ // Create client connection
+ const client = await new Promise((resolve, reject) => {
+ const socket = new WebSocket(`ws://localhost:${server.port}`);
+ socket.onopen = () => resolve(socket);
+ socket.onerror = reject;
+ });
+ ok(client, `Created WebSocket connection to port ${server.port}`);
+
+ // Create a promise that resolves when the WebSocket closes
+ const closed = new Promise(resolve => {
+ client.onclose = resolve;
+ });
+
+ // Send a message
+ const message = "hello there";
+ client.send(message);
+ info("Sent a message to server");
+ // Check that it was echoed
+ const echoedMessage = await new Promise((resolve, reject) => {
+ client.onmessage = ({ data }) => resolve(data);
+ client.onerror = reject;
+ });
+
+ is(echoedMessage, message, "Echoed message matches");
+
+ // Close the connection
+ client.close();
+ await closed;
+
+ // Prevent leaking the streams by closing them before test ends
+ input.close();
+ output.close();
+ });
+};
+</script>
+</body>
+</html>
diff --git a/devtools/server/socket/websocket-server.js b/devtools/server/socket/websocket-server.js
new file mode 100644
index 0000000000..4236ec2921
--- /dev/null
+++ b/devtools/server/socket/websocket-server.js
@@ -0,0 +1,250 @@
+/* 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 { executeSoon } = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ delimitedRead,
+} = require("resource://devtools/shared/transport/stream-utils.js");
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+const threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+
+// Limit the header size to put an upper bound on allocated memory
+const HEADER_MAX_LEN = 8000;
+
+/**
+ * Read a line from async input stream and return promise that resolves to the line once
+ * it has been read. If the line is longer than HEADER_MAX_LEN, will throw error.
+ */
+function readLine(input) {
+ return new Promise((resolve, reject) => {
+ let line = "";
+ const wait = () => {
+ input.asyncWait(
+ stream => {
+ try {
+ const amountToRead = HEADER_MAX_LEN - line.length;
+ line += delimitedRead(input, "\n", amountToRead);
+
+ if (line.endsWith("\n")) {
+ resolve(line.trimRight());
+ return;
+ }
+
+ if (line.length >= HEADER_MAX_LEN) {
+ throw new Error(
+ `Failed to read HTTP header longer than ${HEADER_MAX_LEN} bytes`
+ );
+ }
+
+ wait();
+ } catch (ex) {
+ reject(ex);
+ }
+ },
+ 0,
+ 0,
+ threadManager.currentThread
+ );
+ };
+
+ wait();
+ });
+}
+
+/**
+ * Write a string of bytes to async output stream and return promise that resolves once
+ * all data has been written. Doesn't do any utf-16/utf-8 conversion - the string is
+ * treated as an array of bytes.
+ */
+function writeString(output, data) {
+ return new Promise((resolve, reject) => {
+ const wait = () => {
+ if (data.length === 0) {
+ resolve();
+ return;
+ }
+
+ output.asyncWait(
+ stream => {
+ try {
+ const written = output.write(data, data.length);
+ data = data.slice(written);
+ wait();
+ } catch (ex) {
+ reject(ex);
+ }
+ },
+ 0,
+ 0,
+ threadManager.currentThread
+ );
+ };
+
+ wait();
+ });
+}
+
+/**
+ * Read HTTP request from async input stream.
+ * @return Request line (string) and Map of header names and values.
+ */
+const readHttpRequest = async function (input) {
+ let requestLine = "";
+ const headers = new Map();
+
+ while (true) {
+ const line = await readLine(input);
+ if (!line.length) {
+ break;
+ }
+
+ if (!requestLine) {
+ requestLine = line;
+ } else {
+ const colon = line.indexOf(":");
+ if (colon == -1) {
+ throw new Error(`Malformed HTTP header: ${line}`);
+ }
+
+ const name = line.slice(0, colon).toLowerCase();
+ const value = line.slice(colon + 1).trim();
+ headers.set(name, value);
+ }
+ }
+
+ return { requestLine, headers };
+};
+
+/**
+ * Write HTTP response (array of strings) to async output stream.
+ */
+function writeHttpResponse(output, response) {
+ const responseString = response.join("\r\n") + "\r\n\r\n";
+ return writeString(output, responseString);
+}
+
+/**
+ * Process the WebSocket handshake headers and return the key to be sent in
+ * Sec-WebSocket-Accept response header.
+ */
+function processRequest({ requestLine, headers }) {
+ const [method, path] = requestLine.split(" ");
+ if (method !== "GET") {
+ throw new Error("The handshake request must use GET method");
+ }
+
+ if (path !== "/") {
+ throw new Error("The handshake request has unknown path");
+ }
+
+ const upgrade = headers.get("upgrade");
+ if (!upgrade || upgrade !== "websocket") {
+ throw new Error("The handshake request has incorrect Upgrade header");
+ }
+
+ const connection = headers.get("connection");
+ if (
+ !connection ||
+ !connection
+ .split(",")
+ .map(t => t.trim())
+ .includes("Upgrade")
+ ) {
+ throw new Error("The handshake request has incorrect Connection header");
+ }
+
+ const version = headers.get("sec-websocket-version");
+ if (!version || version !== "13") {
+ throw new Error(
+ "The handshake request must have Sec-WebSocket-Version: 13"
+ );
+ }
+
+ // Compute the accept key
+ const key = headers.get("sec-websocket-key");
+ if (!key) {
+ throw new Error(
+ "The handshake request must have a Sec-WebSocket-Key header"
+ );
+ }
+
+ return { acceptKey: computeKey(key) };
+}
+
+function computeKey(key) {
+ const str = key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+ const data = Array.from(str, ch => ch.charCodeAt(0));
+ const hash = new CryptoHash("sha1");
+ hash.update(data, data.length);
+ return hash.finish(true);
+}
+
+/**
+ * Perform the server part of a WebSocket opening handshake on an incoming connection.
+ */
+const serverHandshake = async function (input, output) {
+ // Read the request
+ const request = await readHttpRequest(input);
+
+ try {
+ // Check and extract info from the request
+ const { acceptKey } = processRequest(request);
+
+ // Send response headers
+ await writeHttpResponse(output, [
+ "HTTP/1.1 101 Switching Protocols",
+ "Upgrade: websocket",
+ "Connection: Upgrade",
+ `Sec-WebSocket-Accept: ${acceptKey}`,
+ ]);
+ } catch (error) {
+ // Send error response in case of error
+ await writeHttpResponse(output, ["HTTP/1.1 400 Bad Request"]);
+ throw error;
+ }
+};
+
+/**
+ * Accept an incoming WebSocket server connection.
+ * Takes an established nsISocketTransport in the parameters.
+ * Performs the WebSocket handshake and waits for the WebSocket to open.
+ * Returns Promise with a WebSocket ready to send and receive messages.
+ */
+const accept = async function (transport, input, output) {
+ await serverHandshake(input, output);
+
+ const transportProvider = {
+ setListener(upgradeListener) {
+ // The onTransportAvailable callback shouldn't be called synchronously.
+ executeSoon(() => {
+ upgradeListener.onTransportAvailable(transport, input, output);
+ });
+ },
+ };
+
+ return new Promise((resolve, reject) => {
+ const socket = WebSocket.createServerWebSocket(
+ null,
+ [],
+ transportProvider,
+ ""
+ );
+ socket.addEventListener("close", () => {
+ input.close();
+ output.close();
+ });
+
+ socket.onopen = () => resolve(socket);
+ socket.onerror = err => reject(err);
+ });
+};
+
+exports.accept = accept;
diff --git a/devtools/server/startup/content-process-script.js b/devtools/server/startup/content-process-script.js
new file mode 100644
index 0000000000..ffd461c4e2
--- /dev/null
+++ b/devtools/server/startup/content-process-script.js
@@ -0,0 +1,282 @@
+/* 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 mozilla/process-script */
+
+"use strict";
+
+/**
+ * Main entry point for DevTools in content processes.
+ *
+ * This module is loaded early when a content process is started.
+ * Note that (at least) JS XPCOM registered at app-startup, will be running before.
+ * It is used by the multiprocess browser toolbox in order to debug privileged resources.
+ * When debugging a Web page loaded in a Tab, DevToolsFrame JS Window Actor is used instead
+ * (DevToolsFrameParent.jsm and DevToolsFrameChild.jsm).
+ *
+ * This module won't do anything unless DevTools codebase starts adding some data
+ * in `Services.cpmm.sharedData` object or send a message manager message via `Services.cpmm`.
+ * Also, this module is only loaded, on-demand from process-helper if devtools are watching for process targets.
+ */
+
+const SHARED_DATA_KEY_NAME = "DevTools:watchedPerWatcher";
+
+class ContentProcessStartup {
+ constructor() {
+ // The map is indexed by the Watcher Actor ID.
+ // The values are objects containing the following properties:
+ // - connection: the DevToolsServerConnection itself
+ // - actor: the ContentProcessTargetActor instance
+ this._connections = new Map();
+
+ this.observe = this.observe.bind(this);
+ this.receiveMessage = this.receiveMessage.bind(this);
+
+ this.addListeners();
+ this.maybeCreateExistingTargetActors();
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "xpcom-shutdown": {
+ this.destroy();
+ break;
+ }
+ }
+ }
+
+ destroy(options) {
+ this.removeListeners();
+
+ for (const [, connectionInfo] of this._connections) {
+ connectionInfo.connection.close(options);
+ }
+ this._connections.clear();
+ }
+
+ addListeners() {
+ Services.obs.addObserver(this.observe, "xpcom-shutdown");
+
+ Services.cpmm.addMessageListener(
+ "debug:instantiate-already-available",
+ this.receiveMessage
+ );
+ Services.cpmm.addMessageListener(
+ "debug:destroy-target",
+ this.receiveMessage
+ );
+ Services.cpmm.addMessageListener(
+ "debug:add-or-set-session-data-entry",
+ this.receiveMessage
+ );
+ Services.cpmm.addMessageListener(
+ "debug:remove-session-data-entry",
+ this.receiveMessage
+ );
+ Services.cpmm.addMessageListener(
+ "debug:destroy-process-script",
+ this.receiveMessage
+ );
+ }
+
+ removeListeners() {
+ Services.obs.removeObserver(this.observe, "xpcom-shutdown");
+
+ Services.cpmm.removeMessageListener(
+ "debug:instantiate-already-available",
+ this.receiveMessage
+ );
+ Services.cpmm.removeMessageListener(
+ "debug:destroy-target",
+ this.receiveMessage
+ );
+ Services.cpmm.removeMessageListener(
+ "debug:add-or-set-session-data-entry",
+ this.receiveMessage
+ );
+ Services.cpmm.removeMessageListener(
+ "debug:remove-session-data-entry",
+ this.receiveMessage
+ );
+ Services.cpmm.removeMessageListener(
+ "debug:destroy-process-script",
+ this.receiveMessage
+ );
+ }
+
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "debug:instantiate-already-available":
+ this.createTargetActor(
+ msg.data.watcherActorID,
+ msg.data.connectionPrefix,
+ msg.data.sessionData,
+ true
+ );
+ break;
+ case "debug:destroy-target":
+ this.destroyTarget(msg.data.watcherActorID);
+ break;
+ case "debug:add-or-set-session-data-entry":
+ this.addOrSetSessionDataEntry(
+ msg.data.watcherActorID,
+ msg.data.type,
+ msg.data.entries,
+ msg.data.updateType
+ );
+ break;
+ case "debug:remove-session-data-entry":
+ this.removeSessionDataEntry(
+ msg.data.watcherActorID,
+ msg.data.type,
+ msg.data.entries
+ );
+ break;
+ case "debug:destroy-process-script":
+ this.destroy(msg.data.options);
+ break;
+ default:
+ throw new Error(`Unsupported message name ${msg.name}`);
+ }
+ }
+
+ /**
+ * Called when the content process just started.
+ * This will start creating ContentProcessTarget actors, but only if DevTools code (WatcherActor / WatcherRegistry.jsm)
+ * put some data in `sharedData` telling us to do so.
+ */
+ maybeCreateExistingTargetActors() {
+ const { sharedData } = Services.cpmm;
+
+ // Accessing `sharedData` right off the app-startup returns null.
+ // Spinning the event loop with dispatchToMainThread seems enough,
+ // but it means that we let some more Javascript code run before
+ // instantiating the target actor.
+ // So we may miss a few resources and will register the breakpoints late.
+ if (!sharedData) {
+ Services.tm.dispatchToMainThread(
+ this.maybeCreateExistingTargetActors.bind(this)
+ );
+ return;
+ }
+
+ const sessionDataByWatcherActor = sharedData.get(SHARED_DATA_KEY_NAME);
+ if (!sessionDataByWatcherActor) {
+ return;
+ }
+
+ // Create one Target actor for each prefix/client which listen to process
+ for (const [watcherActorID, sessionData] of sessionDataByWatcherActor) {
+ const { connectionPrefix, targets } = sessionData;
+ // This is where we only do something significant only if DevTools are opened
+ // and requesting to create target actor for content processes
+ if (targets?.includes("process")) {
+ this.createTargetActor(watcherActorID, connectionPrefix, sessionData);
+ }
+ }
+ }
+
+ /**
+ * Instantiate a new ContentProcessTarget for the given connection.
+ * This is where we start doing some significant computation that only occurs when DevTools are opened.
+ *
+ * @param String watcherActorID
+ * The ID of the WatcherActor who requested to observe and create these target actors.
+ * @param String parentConnectionPrefix
+ * The prefix of the DevToolsServerConnection of the Watcher Actor.
+ * This is used to compute a unique ID for the target actor.
+ * @param Object sessionData
+ * All data managed by the Watcher Actor and WatcherRegistry.jsm, containing
+ * target types, resources types to be listened as well as breakpoints and any
+ * other data meant to be shared across processes and threads.
+ * @param Object options Dictionary with optional values:
+ * @param Boolean options.ignoreAlreadyCreated
+ * If true, do not throw if the target actor has already been created.
+ */
+ createTargetActor(
+ watcherActorID,
+ parentConnectionPrefix,
+ sessionData,
+ ignoreAlreadyCreated = false
+ ) {
+ if (this._connections.get(watcherActorID)) {
+ if (ignoreAlreadyCreated) {
+ return;
+ }
+ throw new Error(
+ "ContentProcessStartup createTargetActor was called more than once" +
+ ` for the Watcher Actor (ID: "${watcherActorID}")`
+ );
+ }
+ // Compute a unique prefix, just for this content process,
+ // which will be used to create a ChildDebuggerTransport pair between content and parent processes.
+ // This is slightly hacky as we typicaly compute Prefix and Actor ID via `DevToolsServerConnection.allocID()`,
+ // but here, we can't have access to any DevTools connection as we are really early in the content process startup
+ const prefix =
+ parentConnectionPrefix + "contentProcess" + Services.appinfo.processID;
+ //TODO: probably merge content-process.jsm with this module
+ const { initContentProcessTarget } = ChromeUtils.importESModule(
+ "resource://devtools/server/startup/content-process.sys.mjs"
+ );
+ const { actor, connection } = initContentProcessTarget({
+ target: Services.cpmm,
+ data: {
+ watcherActorID,
+ parentConnectionPrefix,
+ prefix,
+ sessionContext: sessionData.sessionContext,
+ },
+ });
+ this._connections.set(watcherActorID, {
+ actor,
+ connection,
+ });
+
+ // Pass initialization data to the target actor
+ for (const type in sessionData) {
+ actor.addOrSetSessionDataEntry(type, sessionData[type], false, "set");
+ }
+ }
+
+ destroyTarget(watcherActorID) {
+ const connectionInfo = this._connections.get(watcherActorID);
+ // This connection has already been cleaned?
+ if (!connectionInfo) {
+ throw new Error(
+ `Trying to destroy a content process target actor that doesn't exists, or has already been destroyed. Watcher Actor ID:${watcherActorID}`
+ );
+ }
+ connectionInfo.connection.close();
+ this._connections.delete(watcherActorID);
+ }
+
+ async addOrSetSessionDataEntry(watcherActorID, type, entries, updateType) {
+ const connectionInfo = this._connections.get(watcherActorID);
+ if (!connectionInfo) {
+ throw new Error(
+ `No content process target actor for this Watcher Actor ID:"${watcherActorID}"`
+ );
+ }
+ const { actor } = connectionInfo;
+ await actor.addOrSetSessionDataEntry(type, entries, false, updateType);
+ Services.cpmm.sendAsyncMessage("debug:add-or-set-session-data-entry-done", {
+ watcherActorID,
+ });
+ }
+
+ removeSessionDataEntry(watcherActorID, type, entries) {
+ const connectionInfo = this._connections.get(watcherActorID);
+ if (!connectionInfo) {
+ return;
+ }
+ const { actor } = connectionInfo;
+ actor.removeSessionDataEntry(type, entries);
+ }
+}
+
+// Only start this component for content processes.
+// i.e. explicitely avoid running it for the parent process
+if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
+ new ContentProcessStartup();
+}
diff --git a/devtools/server/startup/content-process.js b/devtools/server/startup/content-process.js
new file mode 100644
index 0000000000..5710220e44
--- /dev/null
+++ b/devtools/server/startup/content-process.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 http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env mozilla/process-script */
+
+"use strict";
+
+/*
+ * Process script that listens for requests to start a `DevToolsServer` for an entire
+ * content process. Loaded into content processes by the main process during
+ * content-process-connector.js' `connectToContentProcess`.
+ *
+ * The actual server startup itself is in a JSM so that code can be cached.
+ */
+
+function onInit(message) {
+ // Only reply if we are in a real content process
+ if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) {
+ const { initContentProcessTarget } = ChromeUtils.importESModule(
+ "resource://devtools/server/startup/content-process.sys.mjs"
+ );
+ initContentProcessTarget(message);
+ }
+}
+
+function onClose() {
+ removeMessageListener("debug:init-content-server", onInit);
+ removeMessageListener("debug:close-content-server", onClose);
+}
+
+addMessageListener("debug:init-content-server", onInit);
+addMessageListener("debug:close-content-server", onClose);
diff --git a/devtools/server/startup/content-process.sys.mjs b/devtools/server/startup/content-process.sys.mjs
new file mode 100644
index 0000000000..fd974e8c4a
--- /dev/null
+++ b/devtools/server/startup/content-process.sys.mjs
@@ -0,0 +1,104 @@
+/* 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/. */
+
+/*
+ * Module that listens for requests to start a `DevToolsServer` for an entire content
+ * process. Loaded into content processes by the main process during
+ * content-process-connector.js' `connectToContentProcess` via the process
+ * script `content-process.js`.
+ *
+ * The actual server startup itself is in this JSM so that code can be cached.
+ */
+
+export function initContentProcessTarget(msg) {
+ const mm = msg.target;
+ const prefix = msg.data.prefix;
+ const watcherActorID = msg.data.watcherActorID;
+
+ // Lazy load Loader.sys.mjs to prevent loading any devtools dependency too early.
+ const {
+ useDistinctSystemPrincipalLoader,
+ releaseDistinctSystemPrincipalLoader,
+ } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs"
+ );
+
+ // Use a unique object to identify this one usage of the loader
+ const loaderRequester = {};
+
+ // Init a custom, invisible DevToolsServer, in order to not pollute the
+ // debugger with all devtools modules, nor break the debugger itself with
+ // using it in the same process.
+ const loader = useDistinctSystemPrincipalLoader(loaderRequester);
+
+ const { DevToolsServer } = loader.require(
+ "resource://devtools/server/devtools-server.js"
+ );
+
+ DevToolsServer.init();
+ // For browser content toolbox, we do need a regular root actor and all tab
+ // actors, but don't need all the "browser actors" that are only useful when
+ // debugging the parent process via the browser toolbox.
+ DevToolsServer.registerActors({ root: true, target: true });
+
+ // Connect both parent/child processes devtools servers RDP via message
+ // managers
+ const conn = DevToolsServer.connectToParent(prefix, mm);
+
+ const { ContentProcessTargetActor } = loader.require(
+ "resource://devtools/server/actors/targets/content-process.js"
+ );
+
+ const actor = new ContentProcessTargetActor(conn, {
+ sessionContext: msg.data.sessionContext,
+ });
+ actor.manage(actor);
+
+ const response = { watcherActorID, prefix, actor: actor.form() };
+ mm.sendAsyncMessage("debug:content-process-actor", response);
+
+ function onDestroy(options) {
+ mm.removeMessageListener(
+ "debug:content-process-disconnect",
+ onContentProcessDisconnect
+ );
+ actor.off("destroyed", onDestroy);
+
+ // Notify the parent process that the actor is being destroyed
+ mm.sendAsyncMessage("debug:content-process-actor-destroyed", {
+ watcherActorID,
+ });
+
+ // Call DevToolsServerConnection.close to destroy all child actors. It should end up
+ // calling DevToolsServerConnection.onTransportClosed that would actually cleanup all actor
+ // pools.
+ conn.close(options);
+
+ // Destroy the related loader when the target is destroyed
+ // and we were the last user of the special loader
+ releaseDistinctSystemPrincipalLoader(loaderRequester);
+ }
+ function onContentProcessDisconnect(message) {
+ if (message.data.prefix != prefix) {
+ // Several copies of this process script can be running for a single process if
+ // we are debugging the same process from multiple clients.
+ // If this disconnect request doesn't match a connection known here, ignore it.
+ return;
+ }
+ onDestroy();
+ }
+
+ // Clean up things when the client disconnects
+ mm.addMessageListener(
+ "debug:content-process-disconnect",
+ onContentProcessDisconnect
+ );
+ // And also when the target actor is destroyed
+ actor.on("destroyed", onDestroy);
+
+ return {
+ actor,
+ connection: conn,
+ };
+}
diff --git a/devtools/server/startup/frame.js b/devtools/server/startup/frame.js
new file mode 100644
index 0000000000..db14f03c15
--- /dev/null
+++ b/devtools/server/startup/frame.js
@@ -0,0 +1,193 @@
+/* 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 mozilla/frame-script */
+
+"use strict";
+
+/* global addEventListener */
+
+/*
+ * Frame script that listens for requests to start a `DevToolsServer` for a frame in a
+ * content process. Loaded into content process frames by the main process during
+ * frame-connector.js' connectToFrame.
+ */
+
+try {
+ var chromeGlobal = this;
+
+ // Encapsulate in its own scope to allows loading this frame script more than once.
+ (function () {
+ // In most cases, we are debugging a tab in content process, without chrome
+ // privileges. But in some tests, we are attaching to privileged document.
+ // 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
+ // invisibleToDebugger, which will force it to be loaded in another compartment.
+ let loader,
+ customLoader = false;
+ if (content.document.nodePrincipal.isSystemPrincipal) {
+ const { useDistinctSystemPrincipalLoader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs"
+ );
+ loader = useDistinctSystemPrincipalLoader(chromeGlobal);
+ customLoader = true;
+ } else {
+ // Otherwise, use the shared loader.
+ loader = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ }
+ const { require } = loader;
+
+ const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+
+ DevToolsServer.init();
+ // We want a special server without any root actor and only target-scoped actors.
+ // We are going to spawn a WindowGlobalTargetActor instance in the next few lines,
+ // it is going to act like a root actor without being one.
+ DevToolsServer.registerActors({ target: true });
+
+ const connections = new Map();
+
+ const onConnect = DevToolsUtils.makeInfallible(function (msg) {
+ const mm = msg.target;
+ const prefix = msg.data.prefix;
+ const addonId = msg.data.addonId;
+ const addonBrowsingContextGroupId = msg.data.addonBrowsingContextGroupId;
+
+ // If we try to create several frame targets simultaneously, the frame script will be loaded several times.
+ // In this case a single "debug:connect" message might be received by all the already loaded frame scripts.
+ // Check if the DevToolsServer already knows the provided connection prefix,
+ // because it means that another framescript instance already handled this message.
+ // Another "debug:connect" message is guaranteed to be emitted for another prefix,
+ // so we keep the message listener and wait for this next message.
+ if (DevToolsServer.hasConnectionForPrefix(prefix)) {
+ return;
+ }
+ removeMessageListener("debug:connect", onConnect);
+
+ const conn = DevToolsServer.connectToParent(prefix, mm);
+ connections.set(prefix, conn);
+
+ let actor;
+
+ if (addonId) {
+ const {
+ WebExtensionTargetActor,
+ } = require("resource://devtools/server/actors/targets/webextension.js");
+ const {
+ createWebExtensionSessionContext,
+ } = require("resource://devtools/server/actors/watcher/session-context.js");
+ const { browsingContext } = docShell;
+ actor = new WebExtensionTargetActor(conn, {
+ addonId,
+ addonBrowsingContextGroupId,
+ chromeGlobal,
+ isTopLevelTarget: true,
+ prefix,
+ sessionContext: createWebExtensionSessionContext(
+ {
+ addonId,
+ browsingContextID: browsingContext.id,
+ innerWindowId: browsingContext.currentWindowContext.innerWindowId,
+ },
+ {
+ isServerTargetSwitchingEnabled:
+ msg.data.isServerTargetSwitchingEnabled,
+ }
+ ),
+ });
+ } else {
+ const {
+ WindowGlobalTargetActor,
+ } = require("resource://devtools/server/actors/targets/window-global.js");
+ const {
+ createBrowserElementSessionContext,
+ } = require("resource://devtools/server/actors/watcher/session-context.js");
+
+ const { docShell } = chromeGlobal;
+ // For a script loaded via loadFrameScript, the global is the content
+ // message manager.
+ // All WindowGlobalTarget actors created via the framescript are top-level
+ // targets. Non top-level WindowGlobalTarget actors are all created by the
+ // DevToolsFrameChild actor.
+ //
+ // createBrowserElementSessionContext only reads browserId attribute
+ const fakeBrowserElement = {
+ browserId: docShell.browsingContext.browserId,
+ };
+ actor = new WindowGlobalTargetActor(conn, {
+ docShell,
+ isTopLevelTarget: true,
+ // This is only used when server target switching is off and we create
+ // the target from TabDescriptor. So all config attributes are false.
+ sessionContext: createBrowserElementSessionContext(
+ fakeBrowserElement,
+ {}
+ ),
+ });
+ }
+ actor.manage(actor);
+
+ sendAsyncMessage("debug:actor", { actor: actor.form(), prefix });
+ });
+
+ addMessageListener("debug:connect", onConnect);
+
+ const onDisconnect = DevToolsUtils.makeInfallible(function (msg) {
+ const prefix = msg.data.prefix;
+ const conn = connections.get(prefix);
+ if (!conn) {
+ // Several copies of this frame script can be running for a single frame since it
+ // is loaded once for each DevTools connection to the frame. If this disconnect
+ // request doesn't match a connection known here, ignore it.
+ return;
+ }
+
+ removeMessageListener("debug:disconnect", onDisconnect);
+ // Call DevToolsServerConnection.close to destroy all child actors. It should end up
+ // calling DevToolsServerConnection.onTransportClosed that would actually cleanup all actor
+ // pools.
+ conn.close();
+ connections.delete(prefix);
+ });
+ addMessageListener("debug:disconnect", onDisconnect);
+
+ // In non-e10s mode, the "debug:disconnect" message isn't always received before the
+ // messageManager connection goes away. Watching for "unload" here ensures we close
+ // any connections when the frame is unloaded.
+ addEventListener("unload", () => {
+ for (const conn of connections.values()) {
+ conn.close();
+ }
+ connections.clear();
+ });
+
+ // Destroy the server once its last connection closes. Note that multiple frame
+ // scripts may be running in parallel and reuse the same server.
+ function destroyLoader() {
+ // Only destroy the server if there is no more connections to it. It may be used
+ // to debug another tab running in the same process.
+ if (DevToolsServer.hasConnection() || DevToolsServer.keepAlive) {
+ return;
+ }
+ DevToolsServer.off("connectionchange", destroyLoader);
+
+ // When debugging chrome pages, we initialized a dedicated loader, also destroy it
+ if (customLoader) {
+ const { releaseDistinctSystemPrincipalLoader } =
+ ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs"
+ );
+ releaseDistinctSystemPrincipalLoader(chromeGlobal);
+ }
+ }
+ DevToolsServer.on("connectionchange", destroyLoader);
+ })();
+} catch (e) {
+ dump(`Exception in DevTools frame startup: ${e}\n`);
+}
diff --git a/devtools/server/startup/moz.build b/devtools/server/startup/moz.build
new file mode 100644
index 0000000000..4237d88599
--- /dev/null
+++ b/devtools/server/startup/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/.
+
+DevToolsModules(
+ "content-process-script.js",
+ "content-process.js",
+ "content-process.sys.mjs",
+ "frame.js",
+ "worker.js",
+)
diff --git a/devtools/server/startup/worker.js b/devtools/server/startup/worker.js
new file mode 100644
index 0000000000..42034831ee
--- /dev/null
+++ b/devtools/server/startup/worker.js
@@ -0,0 +1,159 @@
+/* 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 worker, loadSubScript, global */
+
+/*
+ * Worker debugger script that listens for requests to start a `DevToolsServer` for a
+ * worker in a process. Loaded into a specific worker during worker-connector.js'
+ * `connectToWorker` which is called from the same process as the worker.
+ */
+
+// This function is used to do remote procedure calls from the worker to the
+// main thread. It is exposed as a built-in global to every module by the
+// worker loader. To make sure the worker loader can access it, it needs to be
+// defined before loading the worker loader script below.
+let nextId = 0;
+this.rpc = function (method, ...params) {
+ return new Promise((resolve, reject) => {
+ const id = nextId++;
+ this.addEventListener("message", function onMessageForRpc(event) {
+ const packet = JSON.parse(event.data);
+ if (packet.type !== "rpc" || packet.id !== id) {
+ return;
+ }
+ if (packet.error) {
+ reject(packet.error);
+ } else {
+ resolve(packet.result);
+ }
+ this.removeEventListener("message", onMessageForRpc);
+ });
+
+ postMessage(
+ JSON.stringify({
+ type: "rpc",
+ method,
+ params,
+ id,
+ })
+ );
+ });
+}.bind(this);
+
+loadSubScript("resource://devtools/shared/loader/worker-loader.js");
+
+const { WorkerTargetActor } = worker.require(
+ "resource://devtools/server/actors/targets/worker.js"
+);
+const { DevToolsServer } = worker.require(
+ "resource://devtools/server/devtools-server.js"
+);
+
+DevToolsServer.createRootActor = function () {
+ throw new Error("Should never get here!");
+};
+
+// This file is only instanciated once for a given WorkerDebugger, which means that
+// multiple toolbox could end up using the same instance of this script. In order to handle
+// that, we handle a Map of the different connections, keyed by forwarding prefix.
+const connections = new Map();
+
+this.addEventListener("message", async function (event) {
+ const packet = JSON.parse(event.data);
+ switch (packet.type) {
+ case "connect":
+ const { forwardingPrefix } = packet;
+
+ // Force initializing the server each time on connect
+ // as it may have been destroyed by a previous, now closed toolbox.
+ // Once the last connection drops, the server auto destroy itself.
+ DevToolsServer.init();
+
+ // Step 3: Create a connection to the parent.
+ const connection = DevToolsServer.connectToParent(forwardingPrefix, this);
+
+ // Step 4: Create a WorkerTarget actor.
+ const workerTargetActor = new WorkerTargetActor(
+ connection,
+ global,
+ packet.workerDebuggerData,
+ packet.options.sessionContext
+ );
+ // Make the worker manage itself so it is put in a Pool and assigned an actorID.
+ workerTargetActor.manage(workerTargetActor);
+
+ workerTargetActor.on(
+ "worker-thread-attached",
+ function onThreadAttached() {
+ postMessage(JSON.stringify({ type: "worker-thread-attached" }));
+ }
+ );
+
+ // Step 5: Send a response packet to the parent to notify
+ // it that a connection has been established.
+ connections.set(forwardingPrefix, {
+ connection,
+ workerTargetActor,
+ });
+
+ postMessage(
+ JSON.stringify({
+ type: "connected",
+ forwardingPrefix,
+ workerTargetForm: workerTargetActor.form(),
+ })
+ );
+
+ // We might receive data to watch.
+ if (packet.options.sessionData) {
+ const promises = [];
+ for (const [type, entries] of Object.entries(
+ packet.options.sessionData
+ )) {
+ promises.push(
+ workerTargetActor.addOrSetSessionDataEntry(
+ type,
+ entries,
+ false,
+ "set"
+ )
+ );
+ }
+ await Promise.all(promises);
+ }
+
+ break;
+
+ case "add-or-set-session-data-entry":
+ await connections
+ .get(packet.forwardingPrefix)
+ .workerTargetActor.addOrSetSessionDataEntry(
+ packet.dataEntryType,
+ packet.entries,
+ packet.updateType
+ );
+ postMessage(JSON.stringify({ type: "session-data-entry-added-or-set" }));
+ break;
+
+ case "remove-session-data-entry":
+ await connections
+ .get(packet.forwardingPrefix)
+ .workerTargetActor.removeSessionDataEntry(
+ packet.dataEntryType,
+ packet.entries
+ );
+ break;
+
+ case "disconnect":
+ // This will destroy the associate WorkerTargetActor (and the actors it manages).
+ if (connections.has(packet.forwardingPrefix)) {
+ connections.get(packet.forwardingPrefix).connection.close();
+ connections.delete(packet.forwardingPrefix);
+ }
+ break;
+ }
+});
diff --git a/devtools/server/tests/browser/animation-data.html b/devtools/server/tests/browser/animation-data.html
new file mode 100644
index 0000000000..1ee654cb17
--- /dev/null
+++ b/devtools/server/tests/browser/animation-data.html
@@ -0,0 +1,115 @@
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Animation Test Data</title>
+ <style>
+ .ball {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ background: #f06;
+
+ position: absolute;
+ }
+
+ .still {
+ top: 0;
+ left: 10px;
+ }
+
+ .animated {
+ top: 100px;
+ left: 10px;
+
+ animation: simple-animation 2s infinite alternate;
+ }
+
+ .multi {
+ top: 200px;
+ left: 10px;
+
+ animation: simple-animation 2s infinite alternate,
+ other-animation 5s infinite alternate;
+ }
+
+ .delayed {
+ top: 300px;
+ left: 10px;
+ background: rebeccapurple;
+
+ animation: simple-animation 3s 60s 10;
+ }
+
+ .multi-finite {
+ top: 400px;
+ left: 10px;
+ background: yellow;
+
+ animation: simple-animation 3s,
+ other-animation 4s;
+ }
+
+ .short {
+ top: 500px;
+ left: 10px;
+ background: red;
+
+ animation: simple-animation 2s;
+ }
+
+ .long {
+ top: 600px;
+ left: 10px;
+ background: blue;
+
+ animation: simple-animation 120s;
+ }
+
+ .negative-delay {
+ top: 700px;
+ left: 10px;
+ background: gray;
+
+ animation: simple-animation 15s -10s;
+ animation-fill-mode: forwards;
+ }
+
+ .no-compositor {
+ top: 0;
+ right: 10px;
+ background: gold;
+
+ animation: no-compositor 10s cubic-bezier(.57,-0.02,1,.31) forwards;
+ }
+
+ @keyframes simple-animation {
+ 100% {
+ transform: translateX(300px);
+ }
+ }
+
+ @keyframes other-animation {
+ 100% {
+ background: blue;
+ }
+ }
+
+ @keyframes no-compositor {
+ 100% {
+ margin-right: 600px;
+ }
+ }
+ </style>
+</head>
+</body>
+ <div class="ball still"></div>
+ <div class="ball animated"></div>
+ <div class="ball multi"></div>
+ <div class="ball delayed"></div>
+ <div class="ball multi-finite"></div>
+ <div class="ball short"></div>
+ <div class="ball long"></div>
+ <div class="ball negative-delay"></div>
+ <div class="ball no-compositor"></div>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/animation.html b/devtools/server/tests/browser/animation.html
new file mode 100644
index 0000000000..f7b83df283
--- /dev/null
+++ b/devtools/server/tests/browser/animation.html
@@ -0,0 +1,170 @@
+<!DOCTYPE html>
+<style>
+ .not-animated {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: #eee;
+ }
+
+ .simple-animation {
+ display: inline-block;
+
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: red;
+
+ animation: move 200s infinite;
+ }
+
+ .multiple-animations {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: #eee;
+
+ animation: move 200s infinite , glow 100s 5;
+ animation-timing-function: ease-out;
+ animation-direction: reverse;
+ animation-fill-mode: both;
+ }
+
+ .transition {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: #f06;
+
+ transition: width 500s ease-out;
+ }
+ .transition.get-round {
+ width: 200px;
+ }
+
+ .long-animation {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: gold;
+
+ animation: move 100s;
+ }
+
+ .short-animation {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: purple;
+
+ animation: move 1s;
+ }
+
+ .delayed-animation {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: rebeccapurple;
+
+ animation: move 200s 5s infinite;
+ }
+
+ .delayed-transition {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: black;
+
+ transition: width 500s 3s;
+ }
+ .delayed-transition.get-round {
+ width: 200px;
+ }
+
+ .delayed-multiple-animations {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: green;
+
+ animation: move .5s 1s 10, glow 1s .75s 30;
+ }
+
+ .multiple-animations-2 {
+ display: inline-block;
+
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ background: blue;
+
+ animation: move .5s, glow 100s 2s infinite, grow 300s 1s 100;
+ }
+
+ .all-transitions {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 50px;
+ height: 50px;
+ background: blue;
+ transition: all .2s;
+ }
+ .all-transitions.expand {
+ width: 200px;
+ height: 100px;
+ }
+
+ @keyframes move {
+ 100% {
+ transform: translateY(100px);
+ }
+ }
+
+ @keyframes glow {
+ 100% {
+ background: yellow;
+ }
+ }
+
+ @keyframes grow {
+ 100% {
+ width: 100px;
+ }
+ }
+</style>
+<div class="not-animated"></div>
+<div class="simple-animation"></div>
+<div class="multiple-animations"></div>
+<div class="transition"></div>
+<div class="long-animation"></div>
+<div class="short-animation"></div>
+<div class="delayed-animation"></div>
+<div class="delayed-transition"></div>
+<div class="delayed-multiple-animations"></div>
+<div class="multiple-animations-2"></div>
+<div class="all-transitions"></div>
+<script type="text/javascript">
+ "use strict";
+ // Get the transitions started when the page loads
+ addEventListener("load", function() {
+ document.querySelector(".transition").classList.add("get-round");
+ document.querySelector(".delayed-transition").classList.add("get-round");
+ });
+</script>
diff --git a/devtools/server/tests/browser/application-manifest-404-manifest.html b/devtools/server/tests/browser/application-manifest-404-manifest.html
new file mode 100644
index 0000000000..fd182a69a6
--- /dev/null
+++ b/devtools/server/tests/browser/application-manifest-404-manifest.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Simple manifest</title>
+ <link rel="manifest" href="non-existing-manifest.json">
+</head>
+<body>
+ <p>This page links to a manifest URL that is a 404.</p>
+</body>
diff --git a/devtools/server/tests/browser/application-manifest-basic.html b/devtools/server/tests/browser/application-manifest-basic.html
new file mode 100644
index 0000000000..a8e11a645f
--- /dev/null
+++ b/devtools/server/tests/browser/application-manifest-basic.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Simple manifest</title>
+ <link rel="manifest" href='data:application/manifest+json,{"name": "FooApp"}'>
+</head>
+<body>
+ <pre><code>{ "name": "Foo App" }</code></pre>
+</body>
diff --git a/devtools/server/tests/browser/application-manifest-invalid-json.html b/devtools/server/tests/browser/application-manifest-invalid-json.html
new file mode 100644
index 0000000000..2717a97ddd
--- /dev/null
+++ b/devtools/server/tests/browser/application-manifest-invalid-json.html
@@ -0,0 +1,11 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Invalid JSON</title>
+ <link rel="manifest" href='data:application/manifest+json,foo:'>
+</head>
+<body>
+ <p>Invalid JSON:</p>
+ <pre><code>foo:</code></pre>
+</body>
diff --git a/devtools/server/tests/browser/application-manifest-no-manifest.html b/devtools/server/tests/browser/application-manifest-no-manifest.html
new file mode 100644
index 0000000000..5f0668aa50
--- /dev/null
+++ b/devtools/server/tests/browser/application-manifest-no-manifest.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>No manifest</title>
+</head>
+<body>
+ <p>This page does not link to a manifest</p>
+</body>
diff --git a/devtools/server/tests/browser/application-manifest-warnings.html b/devtools/server/tests/browser/application-manifest-warnings.html
new file mode 100644
index 0000000000..57f8b9b4e7
--- /dev/null
+++ b/devtools/server/tests/browser/application-manifest-warnings.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Empty manifest</title>
+ <link rel="manifest" href='data:application/manifest+json,{"name": 0}'>
+</head>
+<body>
+ <pre><code>{ }</code></pre>
+</body>
diff --git a/devtools/server/tests/browser/browser.toml b/devtools/server/tests/browser/browser.toml
new file mode 100644
index 0000000000..e03ab5649a
--- /dev/null
+++ b/devtools/server/tests/browser/browser.toml
@@ -0,0 +1,213 @@
+[DEFAULT]
+tags = "devtools"
+subsuite = "devtools"
+skip-if = [
+ "http3", # Bug 1829298
+ "http2",
+]
+support-files = [
+ "head.js",
+ "animation.html",
+ "animation-data.html",
+ "application-manifest-404-manifest.html",
+ "application-manifest-basic.html",
+ "application-manifest-invalid-json.html",
+ "application-manifest-no-manifest.html",
+ "application-manifest-warnings.html",
+ "doc_accessibility_audit.html",
+ "doc_accessibility_infobar.html",
+ "doc_accessibility_keyboard_audit.html",
+ "doc_accessibility_text_label_audit_frame.html",
+ "doc_accessibility_text_label_audit.html",
+ "doc_accessibility.html",
+ "doc_allocations.html",
+ "doc_compatibility.html",
+ "doc_force_cc.html",
+ "doc_force_gc.html",
+ "doc_innerHTML.html",
+ "doc_iframe.html",
+ "doc_iframe_content.html",
+ "doc_iframe2.html",
+ "error-actor.js",
+ "grid.html",
+ "inspector-isScrollable-data.html",
+ "inspector-search-data.html",
+ "inspector-traversal-data.html",
+ "inspector-shadow.html",
+ "storage-cookies-same-name.html",
+ "storage-dynamic-windows.html",
+ "storage-listings.html",
+ "storage-unsecured-iframe.html",
+ "storage-updates.html",
+ "storage-secured-iframe.html",
+ "test-errors-actor.js",
+ "test-window.xhtml",
+ "inspector-helpers.js",
+ "storage-helpers.js",
+ "!/devtools/client/shared/test/shared-head.js",
+ "!/devtools/client/shared/test/telemetry-test-helpers.js",
+ "!/devtools/server/tests/chrome/hello-actor.js",
+]
+
+["browser_accessibility_highlighter_infobar.js"]
+
+["browser_accessibility_infobar_audit_keyboard.js"]
+
+["browser_accessibility_infobar_audit_text_label.js"]
+
+["browser_accessibility_infobar_show.js"]
+
+["browser_accessibility_keyboard_audit.js"]
+
+["browser_accessibility_node.js"]
+
+["browser_accessibility_node_audit.js"]
+
+["browser_accessibility_node_events.js"]
+
+["browser_accessibility_node_tabbing_order_highlighter.js"]
+
+["browser_accessibility_simple.js"]
+
+["browser_accessibility_simulator.js"]
+
+["browser_accessibility_tabbing_order_highlighter.js"]
+
+["browser_accessibility_text_label_audit.js"]
+
+["browser_accessibility_text_label_audit_frame.js"]
+
+["browser_accessibility_walker.js"]
+
+["browser_accessibility_walker_audit.js"]
+
+["browser_actor_error.js"]
+
+["browser_animation_actor-lifetime.js"]
+
+["browser_animation_emitMutations.js"]
+
+["browser_animation_getMultipleStates.js"]
+
+["browser_animation_getPlayers.js"]
+
+["browser_animation_getStateAfterFinished.js"]
+
+["browser_animation_getSubTreeAnimations.js"]
+
+["browser_animation_keepFinished.js"]
+
+["browser_animation_playPauseIframe.js"]
+
+["browser_animation_playPauseSeveral.js"]
+
+["browser_animation_playerState.js"]
+
+["browser_animation_reconstructState.js"]
+
+["browser_animation_refreshTransitions.js"]
+
+["browser_animation_setCurrentTime.js"]
+
+["browser_animation_setPlaybackRate.js"]
+
+["browser_animation_simple.js"]
+
+["browser_animation_updatedState.js"]
+
+["browser_application_manifest.js"]
+
+["browser_canvasframe_helper_01.js"]
+skip-if = ["true"] # Bug 1183605
+
+["browser_canvasframe_helper_02.js"]
+skip-if = ["true"] # iframe will not be loaded in xul:window with strict xhtml.
+
+["browser_canvasframe_helper_03.js"]
+skip-if = ["true"] # Bug 1183605
+
+["browser_canvasframe_helper_04.js"]
+skip-if = ["true"] # Bug 1183605
+
+["browser_canvasframe_helper_05.js"]
+skip-if = ["true"] # Bug 1183605
+
+["browser_canvasframe_helper_06.js"]
+skip-if = ["true"] # Bug 1183605
+
+["browser_compatibility_cssIssues.js"]
+
+["browser_connectToFrame.js"]
+
+["browser_debugger_server.js"]
+
+["browser_document_devtools_basics.js"]
+
+["browser_document_rdp_basics.js"]
+
+["browser_getProcess.js"]
+
+["browser_inspector-anonymous.js"]
+
+["browser_inspector-iframe.js"]
+
+["browser_inspector-insert.js"]
+
+["browser_inspector-isScrollable.js"]
+
+["browser_inspector-mutations-childlist.js"]
+
+["browser_inspector-release.js"]
+
+["browser_inspector-remove.js"]
+
+["browser_inspector-retain.js"]
+
+["browser_inspector-search.js"]
+
+["browser_inspector-shadow.js"]
+
+["browser_inspector-traversal.js"]
+
+["browser_inspector-utils.js"]
+
+["browser_layout_getGrids.js"]
+
+["browser_layout_simple.js"]
+
+["browser_memory_allocations_01.js"]
+
+["browser_perf-01.js"]
+skip-if = ["tsan"] # bug 1804081, profiler issues in TSAN
+
+["browser_perf-02.js"]
+skip-if = ["tsan"] # bug 1804081, profiler issues in TSAN
+
+["browser_perf-04.js"]
+skip-if = ["tsan"] # bug 1804081, profiler issues in TSAN
+
+["browser_perf-getSupportedFeatures.js"]
+skip-if = ["tsan"] # bug 1804081, profiler issues in TSAN
+
+["browser_storage_cookies-duplicate-names.js"]
+https_first_disabled = true
+
+["browser_storage_dynamic_windows.js"]
+https_first_disabled = true
+skip-if = [
+ "debug", # Bug 1715916 - test is having race conditions on slow hardware
+ "tsan", # high frequency intermittent
+ "win11_2009 && asan", # high frequency intermittent
+]
+
+["browser_storage_listings.js"]
+https_first_disabled = true
+
+["browser_storage_updates.js"]
+https_first_disabled = true
+
+["browser_style_utils_getFontPreviewData.js"]
+
+["browser_styles_getRuleText.js"]
+
+["browser_stylesheets_getTextEmpty.js"]
diff --git a/devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js b/devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js
new file mode 100644
index 0000000000..0979276230
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js
@@ -0,0 +1,73 @@
+/* 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";
+
+// Test the accessible highlighter's infobar content.
+
+const {
+ truncateString,
+} = require("resource://devtools/shared/inspector/utils.js");
+const {
+ MAX_STRING_LENGTH,
+} = require("resource://devtools/server/actors/highlighters/utils/accessibility.js");
+
+add_task(async function () {
+ const { target, walker, parentAccessibility, a11yWalker } =
+ await initAccessibilityFrontsForUrl(
+ MAIN_DOMAIN + "doc_accessibility_infobar.html"
+ );
+
+ info("Button front checks");
+ await checkNameAndRole(walker, "#button", a11yWalker, "Accessible Button");
+
+ info("Front with long name checks");
+ await checkNameAndRole(
+ walker,
+ "#h1",
+ a11yWalker,
+ "Lorem ipsum dolor sit ame" + "\u2026" + "e et dolore magna aliqua."
+ );
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * A helper function for testing the accessible's displayed name and roles.
+ *
+ * @param {Object} walker
+ * The DOM walker.
+ * @param {String} querySelector
+ * The selector for the node to retrieve accessible from.
+ * @param {Object} a11yWalker
+ * The accessibility walker.
+ * @param {String} expectedName
+ * Expected string content for displaying the accessible's name.
+ * We are testing this in particular because name can be truncated.
+ */
+async function checkNameAndRole(
+ walker,
+ querySelector,
+ a11yWalker,
+ expectedName
+) {
+ const node = await walker.querySelector(walker.rootNode, querySelector);
+ const accessibleFront = await a11yWalker.getAccessibleFor(node);
+
+ const { name, role } = accessibleFront;
+ const onHighlightEvent = a11yWalker.once("highlighter-event");
+
+ await a11yWalker.highlightAccessible(accessibleFront);
+ const { options } = await onHighlightEvent;
+ is(options.name, name, "Accessible highlight has correct name option");
+ is(options.role, role, "Accessible highlight has correct role option");
+
+ is(
+ `"${truncateString(name, MAX_STRING_LENGTH)}"`,
+ `"${expectedName}"`,
+ "Accessible has correct displayed name."
+ );
+}
diff --git a/devtools/server/tests/browser/browser_accessibility_infobar_audit_keyboard.js b/devtools/server/tests/browser/browser_accessibility_infobar_audit_keyboard.js
new file mode 100644
index 0000000000..73fc7127f4
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_infobar_audit_keyboard.js
@@ -0,0 +1,157 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Checks for the AccessibleHighlighter's infobar component and its keyboard
+// audit.
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: MAIN_DOMAIN + "doc_accessibility_infobar.html",
+ },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ AccessibleHighlighter,
+ } = require("resource://devtools/server/actors/highlighters/accessible.js");
+ const {
+ LocalizationHelper,
+ } = require("resource://devtools/shared/l10n.js");
+ const L10N = new LocalizationHelper(
+ "devtools/shared/locales/accessibility.properties"
+ );
+
+ const {
+ accessibility: {
+ AUDIT_TYPE,
+ ISSUE_TYPE: {
+ [AUDIT_TYPE.KEYBOARD]: {
+ INTERACTIVE_NO_ACTION,
+ FOCUSABLE_NO_SEMANTICS,
+ },
+ },
+ SCORES: { FAIL, WARNING },
+ },
+ } = require("resource://devtools/shared/constants.js");
+
+ /**
+ * Checks for updated content for an infobar.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @param {Object} audit
+ * Audit information that is passed on highlighter show.
+ */
+ function checkKeyboard(infobar, audit) {
+ const { issue, score } = audit || {};
+ let expected = "";
+ if (issue) {
+ const { ISSUE_TO_INFOBAR_LABEL_MAP } =
+ infobar.audit.reports[AUDIT_TYPE.KEYBOARD].constructor;
+ expected = L10N.getStr(ISSUE_TO_INFOBAR_LABEL_MAP[issue]);
+ }
+
+ is(
+ infobar.getTextContent("keyboard"),
+ expected,
+ "infobar keyboard audit text content is correct"
+ );
+ if (score) {
+ ok(infobar.getElement("keyboard").classList.contains(score));
+ }
+ }
+
+ // Start testing. First, create highlighter environment and initialize.
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(content.window);
+
+ // Wait for loading highlighter environment content to complete before creating the
+ // highlighter.
+ await new Promise(resolve => {
+ const doc = env.document;
+
+ function onContentLoaded() {
+ if (
+ doc.readyState === "interactive" ||
+ doc.readyState === "complete"
+ ) {
+ resolve();
+ } else {
+ doc.addEventListener("DOMContentLoaded", onContentLoaded, {
+ once: true,
+ });
+ }
+ }
+
+ onContentLoaded();
+ });
+
+ // Now, we can test the Infobar's audit content.
+ const node = content.document.createElement("div");
+ content.document.body.append(node);
+ const highlighter = new AccessibleHighlighter(env);
+ await highlighter.isReady;
+ const infobar = highlighter.accessibleInfobar;
+ const bounds = {
+ x: 0,
+ y: 0,
+ w: 250,
+ h: 100,
+ };
+
+ const tests = [
+ {
+ desc: "Infobar is shown with no keyboard audit content when no audit.",
+ },
+ {
+ desc: "Infobar is shown with no keyboard audit content when audit is null.",
+ audit: null,
+ },
+ {
+ desc:
+ "Infobar is shown with no keyboard audit content when empty " +
+ "keyboard audit.",
+ audit: { [AUDIT_TYPE.KEYBOARD]: null },
+ },
+ {
+ desc: "Infobar is shown with keyboard audit content for an error.",
+ audit: {
+ [AUDIT_TYPE.KEYBOARD]: {
+ score: FAIL,
+ issue: INTERACTIVE_NO_ACTION,
+ },
+ },
+ },
+ {
+ desc: "Infobar is shown with keyboard audit content for a warning.",
+ audit: {
+ [AUDIT_TYPE.KEYBOARD]: {
+ score: WARNING,
+ issue: FOCUSABLE_NO_SEMANTICS,
+ },
+ },
+ },
+ ];
+
+ for (const test of tests) {
+ const { desc, audit } = test;
+
+ info(desc);
+ highlighter.show(node, { ...bounds, audit });
+ checkKeyboard(infobar, audit && audit[AUDIT_TYPE.KEYBOARD]);
+ highlighter.hide();
+ }
+ });
+ }
+ );
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_infobar_audit_text_label.js b/devtools/server/tests/browser/browser_accessibility_infobar_audit_text_label.js
new file mode 100644
index 0000000000..a4e2d895ee
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_infobar_audit_text_label.js
@@ -0,0 +1,164 @@
+/* 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";
+
+// Checks for the AccessibleHighlighter's infobar component and its text label
+// audit.
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: MAIN_DOMAIN + "doc_accessibility_infobar.html",
+ },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ AccessibleHighlighter,
+ } = require("resource://devtools/server/actors/highlighters/accessible.js");
+ const {
+ LocalizationHelper,
+ } = require("resource://devtools/shared/l10n.js");
+ const L10N = new LocalizationHelper(
+ "devtools/shared/locales/accessibility.properties"
+ );
+
+ const {
+ accessibility: {
+ AUDIT_TYPE,
+ ISSUE_TYPE: {
+ [AUDIT_TYPE.TEXT_LABEL]: {
+ DIALOG_NO_NAME,
+ FORM_NO_VISIBLE_NAME,
+ TOOLBAR_NO_NAME,
+ },
+ },
+ SCORES: { BEST_PRACTICES, FAIL, WARNING },
+ },
+ } = require("resource://devtools/shared/constants.js");
+
+ /**
+ * Checks for updated content for an infobar.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @param {Object} audit
+ * Audit information that is passed on highlighter show.
+ */
+ function checkTextLabel(infobar, audit) {
+ const { issue, score } = audit || {};
+ let expected = "";
+ if (issue) {
+ const { ISSUE_TO_INFOBAR_LABEL_MAP } =
+ infobar.audit.reports[AUDIT_TYPE.TEXT_LABEL].constructor;
+ expected = L10N.getStr(ISSUE_TO_INFOBAR_LABEL_MAP[issue]);
+ }
+
+ is(
+ infobar.getTextContent("text-label"),
+ expected,
+ "infobar text label audit text content is correct"
+ );
+ if (score) {
+ ok(infobar.getElement("text-label").classList.contains(score));
+ }
+ }
+
+ // Start testing. First, create highlighter environment and initialize.
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(content.window);
+
+ // Wait for loading highlighter environment content to complete before creating the
+ // highlighter.
+ await new Promise(resolve => {
+ const doc = env.document;
+
+ function onContentLoaded() {
+ if (
+ doc.readyState === "interactive" ||
+ doc.readyState === "complete"
+ ) {
+ resolve();
+ } else {
+ doc.addEventListener("DOMContentLoaded", onContentLoaded, {
+ once: true,
+ });
+ }
+ }
+
+ onContentLoaded();
+ });
+
+ // Now, we can test the Infobar's audit content.
+ const node = content.document.createElement("div");
+ content.document.body.append(node);
+ const highlighter = new AccessibleHighlighter(env);
+ await highlighter.isReady;
+ const infobar = highlighter.accessibleInfobar;
+ const bounds = {
+ x: 0,
+ y: 0,
+ w: 250,
+ h: 100,
+ };
+
+ const tests = [
+ {
+ desc: "Infobar is shown with no text label audit content when no audit.",
+ },
+ {
+ desc: "Infobar is shown with no text label audit content when audit is null.",
+ audit: null,
+ },
+ {
+ desc:
+ "Infobar is shown with no text label audit content when empty " +
+ "text label audit.",
+ audit: { [AUDIT_TYPE.TEXT_LABEL]: null },
+ },
+ {
+ desc: "Infobar is shown with text label audit content for an error.",
+ audit: {
+ [AUDIT_TYPE.TEXT_LABEL]: { score: FAIL, issue: TOOLBAR_NO_NAME },
+ },
+ },
+ {
+ desc: "Infobar is shown with text label audit content for a warning.",
+ audit: {
+ [AUDIT_TYPE.TEXT_LABEL]: {
+ score: WARNING,
+ issue: FORM_NO_VISIBLE_NAME,
+ },
+ },
+ },
+ {
+ desc: "Infobar is shown with text label audit content for best practices.",
+ audit: {
+ [AUDIT_TYPE.TEXT_LABEL]: {
+ score: BEST_PRACTICES,
+ issue: DIALOG_NO_NAME,
+ },
+ },
+ },
+ ];
+
+ for (const test of tests) {
+ const { desc, audit } = test;
+
+ info(desc);
+ highlighter.show(node, { ...bounds, audit });
+ checkTextLabel(infobar, audit && audit[AUDIT_TYPE.TEXT_LABEL]);
+ highlighter.hide();
+ }
+ });
+ }
+ );
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_infobar_show.js b/devtools/server/tests/browser/browser_accessibility_infobar_show.js
new file mode 100644
index 0000000000..9fedf6d3b4
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_infobar_show.js
@@ -0,0 +1,181 @@
+/* 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";
+
+// Checks for the AccessibleHighlighter's and XULWindowHighlighter's infobar components.
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: MAIN_DOMAIN + "doc_accessibility_infobar.html",
+ },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ AccessibleHighlighter,
+ } = require("resource://devtools/server/actors/highlighters/accessible.js");
+
+ /**
+ * Get whether or not infobar container is hidden.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @return {String|null} If the infobar container is hidden.
+ */
+ function isContainerHidden(infobar) {
+ return !!infobar
+ .getElement("infobar-container")
+ .getAttribute("hidden");
+ }
+
+ /**
+ * Get name of accessible object.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @return {String} The text content of the infobar-name element.
+ */
+ function getName(infobar) {
+ return infobar.getTextContent("infobar-name");
+ }
+
+ /**
+ * Get role of accessible object.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @return {String} The text content of the infobar-role element.
+ */
+ function getRole(infobar) {
+ return infobar.getTextContent("infobar-role");
+ }
+
+ /**
+ * Checks for updated content for an infobar with valid bounds.
+ *
+ * @param {Object} infobar
+ * Accessible highlighter's infobar component.
+ * @param {Object} options
+ * Options to pass for the highlighter's show method.
+ * Available options:
+ * - {String} role
+ * Role value of the accessible.
+ * - {String} name
+ * Name value of the accessible.
+ * - {Boolean} shouldBeHidden
+ * If the infobar component should be hidden.
+ */
+ function checkInfobar(infobar, { shouldBeHidden, role, name }) {
+ is(
+ isContainerHidden(infobar),
+ shouldBeHidden,
+ "Infobar's hidden state is correct."
+ );
+
+ if (shouldBeHidden) {
+ return;
+ }
+
+ is(getRole(infobar), role, "infobarRole text content is correct");
+ is(
+ getName(infobar),
+ `"${name}"`,
+ "infoBarName text content is correct"
+ );
+ }
+
+ /**
+ * Checks for updated content of an infobar with valid bounds.
+ *
+ * @param {Element} node
+ * Node to check infobar content on.
+ * @param {Object} highlighter
+ * Accessible highlighter.
+ */
+ function testInfobar(node, highlighter) {
+ const infobar = highlighter.accessibleInfobar;
+ const bounds = {
+ x: 0,
+ y: 0,
+ w: 250,
+ h: 100,
+ };
+
+ info("Check that infobar is shown with valid bounds.");
+ highlighter.show(node, {
+ ...bounds,
+ role: "button",
+ name: "Accessible Button",
+ });
+
+ checkInfobar(infobar, {
+ role: "button",
+ name: "Accessible Button",
+ shouldBeHidden: false,
+ });
+ highlighter.hide();
+
+ info("Check that infobar is hidden after .hide() is called.");
+ checkInfobar(infobar, { shouldBeHidden: true });
+
+ info("Check to make sure content is updated with new options.");
+ highlighter.show(node, {
+ ...bounds,
+ name: "Test link",
+ role: "link",
+ });
+ checkInfobar(infobar, {
+ name: "Test link",
+ role: "link",
+ shouldBeHidden: false,
+ });
+ highlighter.hide();
+ }
+
+ // Start testing. First, create highlighter environment and initialize.
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(content.window);
+
+ // Wait for loading highlighter environment content to complete before creating the
+ // highlighter.
+ await new Promise(resolve => {
+ const doc = env.document;
+
+ function onContentLoaded() {
+ if (
+ doc.readyState === "interactive" ||
+ doc.readyState === "complete"
+ ) {
+ resolve();
+ } else {
+ doc.addEventListener("DOMContentLoaded", onContentLoaded, {
+ once: true,
+ });
+ }
+ }
+
+ onContentLoaded();
+ });
+
+ // Now, we can test the Infobar and XULWindowInfobar components with their
+ // respective highlighters.
+ const node = content.document.createElement("div");
+ content.document.body.append(node);
+
+ info("Checks for Infobar's show method");
+ const highlighter = new AccessibleHighlighter(env);
+ await highlighter.isReady;
+ testInfobar(node, highlighter);
+ });
+ }
+ );
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_keyboard_audit.js b/devtools/server/tests/browser/browser_accessibility_keyboard_audit.js
new file mode 100644
index 0000000000..fee9814b6c
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_keyboard_audit.js
@@ -0,0 +1,367 @@
+/* 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";
+
+/**
+ * Checks functionality around text label audit for the AccessibleActor.
+ */
+
+const {
+ accessibility: {
+ AUDIT_TYPE: { KEYBOARD },
+ SCORES: { FAIL, WARNING },
+ ISSUE_TYPE: {
+ [KEYBOARD]: {
+ FOCUSABLE_NO_SEMANTICS,
+ FOCUSABLE_POSITIVE_TABINDEX,
+ INTERACTIVE_NOT_FOCUSABLE,
+ MOUSE_INTERACTIVE_ONLY,
+ NO_FOCUS_VISIBLE,
+ },
+ },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+add_task(async function () {
+ const { target, walker, parentAccessibility, a11yWalker } =
+ await initAccessibilityFrontsForUrl(
+ `${MAIN_DOMAIN}doc_accessibility_keyboard_audit.html`
+ );
+
+ const tests = [
+ [
+ "Focusable element (styled button) with no semantics.",
+ "#button-1",
+ { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS },
+ ],
+ ["Element (styled button) with no semantics.", "#button-2", null],
+ [
+ "Container element for out of order focusable element.",
+ "#input-container",
+ null,
+ ],
+ [
+ "Interactive element with focus out of order (-1).",
+ "#input-1",
+ {
+ score: FAIL,
+ issue: INTERACTIVE_NOT_FOCUSABLE,
+ },
+ ],
+ [
+ "Interactive element with focus out of order (-1) when disabled.",
+ "#input-2",
+ null,
+ ],
+ ["Interactive element when disabled.", "#input-3", null],
+ ["Focusable interactive element.", "#input-4", null],
+ [
+ "Interactive accesible (link with no attributes) with no accessible actions.",
+ "#link-1",
+ null,
+ ],
+ ["Interactive accessible (link with valid href).", "#link-2", null],
+ ["Interactive accessible (link with # as href).", "#link-3", null],
+ [
+ "Interactive accessible (link with empty string as href).",
+ "#link-4",
+ null,
+ ],
+ ["Interactive accessible with no tabindex.", "#button-3", null],
+ [
+ "Interactive accessible with -1 tabindex.",
+ "#button-4",
+ {
+ score: FAIL,
+ issue: INTERACTIVE_NOT_FOCUSABLE,
+ },
+ ],
+ ["Interactive accessible with 0 tabindex.", "#button-5", null],
+ [
+ "Interactive accessible with 1 tabindex.",
+ "#button-6",
+ { score: WARNING, issue: FOCUSABLE_POSITIVE_TABINDEX },
+ ],
+ [
+ "Focusable ARIA button with no focus styling.",
+ "#focusable-1",
+ { score: WARNING, issue: NO_FOCUS_VISIBLE },
+ ],
+ ["Focusable ARIA button with focus styling.", "#focusable-2", null],
+ ["Focusable ARIA button with browser styling.", "#focusable-3", null],
+ [
+ "Not focusable, non-semantic element that has a click handler.",
+ "#mouse-only-1",
+ { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY },
+ ],
+ [
+ "Focusable, non-semantic element that has a click handler.",
+ "#focusable-4",
+ { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS },
+ ],
+ [
+ "Not focusable, ARIA button that has a click handler.",
+ "#button-7",
+ { score: FAIL, issue: INTERACTIVE_NOT_FOCUSABLE },
+ ],
+ ["Focusable, ARIA button with a click handler.", "#button-8", null],
+ ["Regular image, no keyboard checks should flag an issue.", "#img-1", null],
+ [
+ "Image with a longdesc (accessible will have showlongdesc action).",
+ "#img-2",
+ null,
+ ],
+ [
+ "Clickable image with a longdesc (accessible will have click and showlongdesc actions).",
+ "#img-3",
+ { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY },
+ ],
+ [
+ "Clickable image (accessible will have click action).",
+ "#img-4",
+ { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY },
+ ],
+ ["Focusable button with aria-haspopup.", "#buttonmenu-1", null],
+ [
+ "Not focusable aria button with aria-haspopup.",
+ "#buttonmenu-2",
+ {
+ score: FAIL,
+ issue: INTERACTIVE_NOT_FOCUSABLE,
+ },
+ ],
+ ["Focusable checkbox.", "#checkbox-1", null],
+ ["Focusable select element size > 1", "#listbox-1", null],
+ ["Focusable select element with one option", "#combobox-1", null],
+ ["Focusable select element with no options", "#combobox-2", null],
+ ["Focusable select element with two options", "#combobox-3", null],
+ [
+ "Non-focusable aria combobox with one aria option.",
+ "#editcombobox-1",
+ null,
+ ],
+ ["Non-focusable aria combobox with no options.", "#editcombobox-2", null],
+ ["Focusable aria combobox with no options.", "#editcombobox-3", null],
+ [
+ "Non-focusable aria switch",
+ "#switch-1",
+ {
+ score: FAIL,
+ issue: INTERACTIVE_NOT_FOCUSABLE,
+ },
+ ],
+ ["Focusable aria switch", "#switch-2", null],
+ [
+ "Combobox list that is visible (has focusable state)",
+ "#owned_listbox",
+ null,
+ ],
+ [
+ "Mouse interactive, label that contains form element (linked)",
+ "#label-1",
+ null,
+ ],
+ ["Mouse interactive label for external element (linked)", "#label-2", null],
+ ["Not interactive unlinked label", "#label-3", null],
+ [
+ "Not interactive unlinked label with folloing form element",
+ "#label-4",
+ null,
+ ],
+ ["Image inside an anchor (href)", "#img-5", null],
+ ["Image inside an anchor (onmousedown)", "#img-6", null],
+ ["Image inside an anchor (onclick)", "#img-7", null],
+ ["Image inside an anchor (onmouseup)", "#img-8", null],
+ [
+ "Section with a collapse action from aria-expanded attribute",
+ "#section-1",
+ null,
+ ],
+ ["Tabindex -1 should not report an element as focusable", "#main", null],
+ [
+ "Not keyboard focusable element with no focus styling.",
+ "#not-keyboard-focusable-1",
+ null,
+ ],
+ ["Interactive grid that is not focusable.", "#grid-1", null],
+ ["Focusable interactive grid.", "#grid-2", null],
+ [
+ "Non interactive ARIA table does not need to be focusable.",
+ "#table-1",
+ null,
+ ],
+ [
+ "Focusable ARIA table does not have interactive semantics",
+ "#table-2",
+ { score: "WARNING", issue: "FOCUSABLE_NO_SEMANTICS" },
+ ],
+ ["Non interactive table does not need to be focusable.", "#table-3", null],
+ [
+ "Focusable table does not have interactive semantics",
+ "#table-4",
+ { score: "WARNING", issue: "FOCUSABLE_NO_SEMANTICS" },
+ ],
+ [
+ "Article that is not focusable is not considered interactive",
+ "#article-1",
+ null,
+ ],
+ ["Focusable article is considered interactive", "#article-2", null],
+ [
+ "Column header that is not focusable is not considered interactive (ARIA grid)",
+ "#columnheader-1",
+ null,
+ ],
+ [
+ "Column header that is not focusable is not considered interactive (ARIA table)",
+ "#columnheader-2",
+ null,
+ ],
+ [
+ "Column header that is not focusable is not considered interactive (table)",
+ "#columnheader-3",
+ null,
+ ],
+ [
+ "Column header that is focusable is considered interactive (table)",
+ "#columnheader-4",
+ null,
+ ],
+ [
+ "Column header that is not focusable is not considered interactive (table as ARIA grid)",
+ "#columnheader-5",
+ null,
+ ],
+ [
+ "Column header that is focusable is considered interactive (table as ARIA grid)",
+ "#columnheader-6",
+ null,
+ ],
+ [
+ "Row header that is not focusable is not considered interactive",
+ "#rowheader-1",
+ null,
+ ],
+ [
+ "Row header that is not focusable is not considered interactive",
+ "#rowheader-2",
+ null,
+ ],
+ [
+ "Row header that is not focusable is not considered interactive",
+ "#rowheader-3",
+ null,
+ ],
+ [
+ "Row header that is focusable is considered interactive",
+ "#rowheader-4",
+ null,
+ ],
+ [
+ "Row header that is not focusable is not considered interactive (table as ARIA grid)",
+ "#rowheader-5",
+ null,
+ ],
+ [
+ "Row header that is focusable is considered interactive (table as ARIA grid)",
+ "#rowheader-6",
+ null,
+ ],
+ [
+ "Gridcell that is not focusable is not considered interactive (ARIA grid)",
+ "#gridcell-1",
+ null,
+ ],
+ [
+ "Gridcell that is focusable is considered interactive (ARIA grid)",
+ "#gridcell-2",
+ null,
+ ],
+ [
+ "Gridcell that is not focusable is not considered interactive (table as ARIA grid)",
+ "#gridcell-3",
+ null,
+ ],
+ [
+ "Gridcell that is focusable is considered interactive (table as ARIA grid)",
+ "#gridcell-4",
+ null,
+ ],
+ [
+ "Tab list that is not focusable is not considered interactive",
+ "#tablist-1",
+ null,
+ ],
+ ["Focusable tab list is considered interactive", "#tablist-2", null],
+ [
+ "Scrollbar that is not focusable is not considered interactive",
+ "#scrollbar-1",
+ null,
+ ],
+ ["Focusable scrollbar is considered interactive", "#scrollbar-2", null],
+ [
+ "Separator that is not focusable is not considered interactive",
+ "#separator-1",
+ null,
+ ],
+ ["Focusable separator is considered interactive", "#separator-2", null],
+ [
+ "Toolbar that is not focusable is not considered interactive",
+ "#toolbar-1",
+ null,
+ ],
+ ["Focusable toolbar is considered interactive", "#toolbar-2", null],
+ [
+ "Menu popup that is not focusable is not considered interactive",
+ "#menu-1",
+ null,
+ ],
+ ["Focusable menu popup is considered interactive", "#menu-2", null],
+ [
+ "Menubar that is not focusable is not considered interactive",
+ "#menubar-1",
+ null,
+ ],
+ ["Focusable menubar is considered interactive", "#menubar-2", null],
+ ];
+
+ for (const [description, selector, expected] of tests) {
+ info(description);
+ const node = await walker.querySelector(walker.rootNode, selector);
+ const front = await a11yWalker.getAccessibleFor(node);
+ const audit = await front.audit({ types: [KEYBOARD] });
+ Assert.deepEqual(
+ audit[KEYBOARD],
+ expected,
+ `Audit result for ${selector} is correct.`
+ );
+ }
+
+ info("Text leaf inside a link (jump action is propagated to the text link)");
+ let node = await walker.querySelector(walker.rootNode, "#link-5");
+ let parent = await a11yWalker.getAccessibleFor(node);
+ let front = (await parent.children())[0];
+ let audit = await front.audit({ types: [KEYBOARD] });
+ Assert.deepEqual(
+ audit[KEYBOARD],
+ null,
+ "Text leafs are excluded from semantics rule."
+ );
+
+ info("Combobox list that is invisible");
+ node = await walker.querySelector(walker.rootNode, "#combobox-1");
+ parent = await a11yWalker.getAccessibleFor(node);
+ front = (await parent.children())[0];
+ audit = await front.audit({ types: [KEYBOARD] });
+ Assert.deepEqual(
+ audit[KEYBOARD],
+ null,
+ "Combobox lists (invisible) are excluded from semantics rule."
+ );
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_node.js b/devtools/server/tests/browser/browser_accessibility_node.js
new file mode 100644
index 0000000000..7aff9d9a5d
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_node.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";
+
+// Checks for the AccessibleActor
+
+add_task(async function () {
+ const { target, walker, a11yWalker, parentAccessibility } =
+ await initAccessibilityFrontsForUrl(MAIN_DOMAIN + "doc_accessibility.html");
+ const modifiers =
+ Services.appinfo.OS === "Darwin" ? "\u2303\u2325" : "Alt+Shift+";
+
+ const buttonNode = await walker.querySelector(walker.rootNode, "#button");
+ const accessibleFront = await a11yWalker.getAccessibleFor(buttonNode);
+
+ checkA11yFront(accessibleFront, {
+ name: "Accessible Button",
+ role: "button",
+ childCount: 1,
+ });
+
+ await accessibleFront.hydrate();
+
+ checkA11yFront(accessibleFront, {
+ name: "Accessible Button",
+ role: "button",
+ value: "",
+ description: "Accessibility Test",
+ keyboardShortcut: modifiers + "b",
+ childCount: 1,
+ domNodeType: 1,
+ indexInParent: 1,
+ states: ["focusable", "opaque", "enabled", "sensitive"],
+ actions: ["Press"],
+ attributes: {
+ "margin-top": "0px",
+ display: "inline-block",
+ "text-align": "center",
+ "text-indent": "0px",
+ "margin-left": "0px",
+ tag: "button",
+ "margin-right": "0px",
+ id: "button",
+ "margin-bottom": "0px",
+ },
+ });
+
+ info("Children");
+ const children = await accessibleFront.children();
+ is(children.length, 1, "Accessible Front has correct number of children");
+ checkA11yFront(children[0], {
+ name: "Accessible Button",
+ role: "text leaf",
+ });
+
+ info("Relations");
+ const labelNode = await walker.querySelector(walker.rootNode, "#label");
+ const controlNode = await walker.querySelector(walker.rootNode, "#control");
+ const labelAccessibleFront = await a11yWalker.getAccessibleFor(labelNode);
+ const controlAccessibleFront = await a11yWalker.getAccessibleFor(controlNode);
+ const docAccessibleFront = await a11yWalker.getAccessibleFor(walker.rootNode);
+ const labelRelations = await labelAccessibleFront.getRelations();
+ is(labelRelations.length, 2, "Label has correct number of relations");
+ is(labelRelations[0].type, "label for", "Label has a label for relation");
+ is(labelRelations[0].targets.length, 1, "Label is a label for one target");
+ is(
+ labelRelations[0].targets[0],
+ controlAccessibleFront,
+ "Label is a label for control accessible front"
+ );
+ is(
+ labelRelations[1].type,
+ "containing document",
+ "Label has a containing document relation"
+ );
+ is(
+ labelRelations[1].targets.length,
+ 1,
+ "Label is contained by just one document"
+ );
+ is(
+ labelRelations[1].targets[0],
+ docAccessibleFront,
+ "Label's containing document is a root document"
+ );
+
+ const controlRelations = await controlAccessibleFront.getRelations();
+ is(controlRelations.length, 3, "Control has correct number of relations");
+ is(controlRelations[2].type, "details", "Control has a details relation");
+ is(controlRelations[2].targets.length, 1, "Control has one details target");
+ const detailsNode = await walker.querySelector(walker.rootNode, "#details");
+ const detailsAccessibleFront = await a11yWalker.getAccessibleFor(detailsNode);
+ is(
+ controlRelations[2].targets[0],
+ detailsAccessibleFront,
+ "Control has correct details target"
+ );
+
+ info("Snapshot");
+ const snapshot = await controlAccessibleFront.snapshot();
+ Assert.deepEqual(snapshot, {
+ name: "Label",
+ role: "textbox",
+ actions: ["Activate"],
+ value: "",
+ nodeCssSelector: "#control",
+ nodeType: 1,
+ description: "",
+ keyboardShortcut: "",
+ childCount: 0,
+ indexInParent: 1,
+ states: [
+ "focusable",
+ "autocompletion",
+ "selectable text",
+ "editable",
+ "opaque",
+ "single line",
+ "enabled",
+ "sensitive",
+ ],
+ children: [],
+ attributes: {
+ "margin-left": "0px",
+ "text-align": "start",
+ "text-indent": "0px",
+ id: "control",
+ tag: "input",
+ "margin-top": "0px",
+ "margin-bottom": "0px",
+ "margin-right": "0px",
+ display: "inline-block",
+ "explicit-name": "true",
+ },
+ });
+
+ // Check that we're using ARIA role tokens for landmarks implicit in native
+ // markup.
+ const headerNode = await walker.querySelector(walker.rootNode, "#header");
+ const headerAccessibleFront = await a11yWalker.getAccessibleFor(headerNode);
+ checkA11yFront(headerAccessibleFront, {
+ name: null,
+ role: "banner",
+ childCount: 1,
+ });
+ const navNode = await walker.querySelector(walker.rootNode, "#nav");
+ const navAccessibleFront = await a11yWalker.getAccessibleFor(navNode);
+ checkA11yFront(navAccessibleFront, {
+ name: null,
+ role: "navigation",
+ childCount: 1,
+ });
+ const footerNode = await walker.querySelector(walker.rootNode, "#footer");
+ const footerAccessibleFront = await a11yWalker.getAccessibleFor(footerNode);
+ checkA11yFront(footerAccessibleFront, {
+ name: null,
+ role: "contentinfo",
+ childCount: 1,
+ });
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_node_audit.js b/devtools/server/tests/browser/browser_accessibility_node_audit.js
new file mode 100644
index 0000000000..a3115a2846
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_node_audit.js
@@ -0,0 +1,116 @@
+/* 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";
+
+/**
+ * Checks functionality around audit for the AccessibleActor. This includes
+ * tests for the return value when calling the audit method, payload of the
+ * corresponding event as well as the AccesibleFront state being up to date.
+ */
+
+const {
+ accessibility: { AUDIT_TYPE, SCORES },
+} = require("resource://devtools/shared/constants.js");
+const EMPTY_AUDIT = Object.keys(AUDIT_TYPE).reduce((audit, key) => {
+ audit[key] = null;
+ return audit;
+}, {});
+
+const EXPECTED_CONTRAST_DATA = {
+ value: 21,
+ color: [0, 0, 0, 1],
+ backgroundColor: [255, 255, 255, 1],
+ isLargeText: true,
+ score: SCORES.AAA,
+};
+
+const EMPTY_CONTRAST_AUDIT = {
+ [AUDIT_TYPE.CONTRAST]: null,
+};
+
+const CONTRAST_AUDIT = {
+ [AUDIT_TYPE.CONTRAST]: EXPECTED_CONTRAST_DATA,
+};
+
+const FULL_AUDIT = {
+ ...EMPTY_AUDIT,
+ [AUDIT_TYPE.CONTRAST]: EXPECTED_CONTRAST_DATA,
+};
+
+async function checkAudit(a11yWalker, node, expected, options) {
+ const front = await a11yWalker.getAccessibleFor(node);
+ const [textLeafNode] = await front.children();
+
+ const onAudited = textLeafNode.once("audited");
+ const audit = await textLeafNode.audit(options);
+ const auditFromEvent = await onAudited;
+
+ Assert.deepEqual(audit, expected.audit, "Audit results are correct.");
+ Assert.deepEqual(textLeafNode.checks, expected.checks, "Checks are correct.");
+ Assert.deepEqual(
+ auditFromEvent,
+ expected.audit,
+ "Audit results from event are correct."
+ );
+}
+
+add_task(async function () {
+ const { target, walker, a11yWalker, parentAccessibility } =
+ await initAccessibilityFrontsForUrl(
+ MAIN_DOMAIN + "doc_accessibility_infobar.html"
+ );
+
+ const headerNode = await walker.querySelector(walker.rootNode, "#h1");
+ await checkAudit(
+ a11yWalker,
+ headerNode,
+ { audit: CONTRAST_AUDIT, checks: CONTRAST_AUDIT },
+ { types: [AUDIT_TYPE.CONTRAST] }
+ );
+ await checkAudit(a11yWalker, headerNode, {
+ audit: FULL_AUDIT,
+ checks: FULL_AUDIT,
+ });
+ await checkAudit(
+ a11yWalker,
+ headerNode,
+ { audit: CONTRAST_AUDIT, checks: FULL_AUDIT },
+ { types: [AUDIT_TYPE.CONTRAST] }
+ );
+ await checkAudit(
+ a11yWalker,
+ headerNode,
+ { audit: FULL_AUDIT, checks: FULL_AUDIT },
+ { types: [] }
+ );
+
+ const paragraphNode = await walker.querySelector(walker.rootNode, "#p");
+ await checkAudit(
+ a11yWalker,
+ paragraphNode,
+ { audit: EMPTY_CONTRAST_AUDIT, checks: EMPTY_CONTRAST_AUDIT },
+ { types: [AUDIT_TYPE.CONTRAST] }
+ );
+ await checkAudit(a11yWalker, paragraphNode, {
+ audit: EMPTY_AUDIT,
+ checks: EMPTY_AUDIT,
+ });
+ await checkAudit(
+ a11yWalker,
+ paragraphNode,
+ { audit: EMPTY_CONTRAST_AUDIT, checks: EMPTY_AUDIT },
+ { types: [AUDIT_TYPE.CONTRAST] }
+ );
+ await checkAudit(
+ a11yWalker,
+ paragraphNode,
+ { audit: EMPTY_AUDIT, checks: EMPTY_AUDIT },
+ { types: [] }
+ );
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_node_events.js b/devtools/server/tests/browser/browser_accessibility_node_events.js
new file mode 100644
index 0000000000..77a1e7892f
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_node_events.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 http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+// Checks for the AccessibleActor events
+
+add_task(async function () {
+ const { target, walker, a11yWalker, parentAccessibility } =
+ await initAccessibilityFrontsForUrl(MAIN_DOMAIN + "doc_accessibility.html");
+ const modifiers =
+ Services.appinfo.OS === "Darwin" ? "\u2303\u2325" : "Alt+Shift+";
+
+ const rootNode = await walker.getRootNode();
+ const a11yDoc = await a11yWalker.getAccessibleFor(rootNode);
+ const buttonNode = await walker.querySelector(walker.rootNode, "#button");
+ const accessibleFront = await a11yWalker.getAccessibleFor(buttonNode);
+ const sliderNode = await walker.querySelector(walker.rootNode, "#slider");
+ const accessibleSliderFront = await a11yWalker.getAccessibleFor(sliderNode);
+ const browser = gBrowser.selectedBrowser;
+
+ checkA11yFront(accessibleFront, {
+ name: "Accessible Button",
+ role: "button",
+ childCount: 1,
+ });
+
+ await accessibleFront.hydrate();
+
+ checkA11yFront(accessibleFront, {
+ name: "Accessible Button",
+ role: "button",
+ value: "",
+ description: "Accessibility Test",
+ keyboardShortcut: modifiers + "b",
+ childCount: 1,
+ domNodeType: 1,
+ indexInParent: 1,
+ states: ["focusable", "opaque", "enabled", "sensitive"],
+ actions: ["Press"],
+ attributes: {
+ "margin-top": "0px",
+ display: "inline-block",
+ "text-align": "center",
+ "text-indent": "0px",
+ "margin-left": "0px",
+ tag: "button",
+ "margin-right": "0px",
+ id: "button",
+ "margin-bottom": "0px",
+ },
+ });
+
+ info("Name change event");
+ await emitA11yEvent(
+ accessibleFront,
+ "name-change",
+ (name, parent) => {
+ checkA11yFront(accessibleFront, { name: "Renamed" });
+ checkA11yFront(parent, {}, a11yDoc);
+ },
+ () =>
+ SpecialPowers.spawn(browser, [], () =>
+ content.document
+ .getElementById("button")
+ .setAttribute("aria-label", "Renamed")
+ )
+ );
+
+ info("Description change event");
+ await emitA11yEvent(
+ accessibleFront,
+ "description-change",
+ () => checkA11yFront(accessibleFront, { description: "" }),
+ () =>
+ SpecialPowers.spawn(browser, [], () =>
+ content.document
+ .getElementById("button")
+ .removeAttribute("aria-describedby")
+ )
+ );
+
+ info("State change event");
+ const expectedStates = ["unavailable", "opaque"];
+ await emitA11yEvent(
+ accessibleFront,
+ "states-change",
+ newStates => {
+ checkA11yFront(accessibleFront, { states: expectedStates });
+ SimpleTest.isDeeply(newStates, expectedStates, "States are updated");
+ },
+ () =>
+ SpecialPowers.spawn(browser, [], () =>
+ content.document.getElementById("button").setAttribute("disabled", true)
+ )
+ );
+
+ info("Attributes change event");
+ await emitA11yEvent(
+ accessibleFront,
+ "attributes-change",
+ newAttrs => {
+ checkA11yFront(accessibleFront, {
+ attributes: {
+ "container-live": "polite",
+ display: "inline-block",
+ "event-from-input": "false",
+ "explicit-name": "true",
+ id: "button",
+ live: "polite",
+ "margin-bottom": "0px",
+ "margin-left": "0px",
+ "margin-right": "0px",
+ "margin-top": "0px",
+ tag: "button",
+ "text-align": "center",
+ "text-indent": "0px",
+ },
+ });
+ is(newAttrs.live, "polite", "Attributes are updated");
+ },
+ () =>
+ SpecialPowers.spawn(browser, [], () =>
+ content.document
+ .getElementById("button")
+ .setAttribute("aria-live", "polite")
+ )
+ );
+
+ info("Value change event");
+ await accessibleSliderFront.hydrate();
+ checkA11yFront(accessibleSliderFront, { value: "5" });
+ await emitA11yEvent(
+ accessibleSliderFront,
+ "value-change",
+ () => checkA11yFront(accessibleSliderFront, { value: "6" }),
+ () =>
+ SpecialPowers.spawn(browser, [], () =>
+ content.document
+ .getElementById("slider")
+ .setAttribute("aria-valuenow", "6")
+ )
+ );
+
+ info("Reorder event");
+ is(accessibleSliderFront.childCount, 1, "Slider has only 1 child");
+ const [firstChild] = await accessibleSliderFront.children();
+ await firstChild.hydrate();
+ is(
+ firstChild.indexInParent,
+ 0,
+ "Slider's first child has correct index in parent"
+ );
+ await emitA11yEvent(
+ accessibleSliderFront,
+ "reorder",
+ childCount => {
+ is(childCount, 2, "Child count is updated");
+ is(accessibleSliderFront.childCount, 2, "Child count is updated");
+ is(
+ firstChild.indexInParent,
+ 1,
+ "Slider's first child has an updated index in parent"
+ );
+ },
+ () =>
+ SpecialPowers.spawn(browser, [], () => {
+ const doc = content.document;
+ const slider = doc.getElementById("slider");
+ const button = doc.createElement("button");
+ button.innerText = "Slider button";
+ content.document
+ .getElementById("slider")
+ .insertBefore(button, slider.firstChild);
+ })
+ );
+
+ await emitA11yEvent(
+ firstChild,
+ "index-in-parent-change",
+ indexInParent =>
+ is(
+ indexInParent,
+ 0,
+ "Slider's first child has an updated index in parent"
+ ),
+ () =>
+ SpecialPowers.spawn(browser, [], () =>
+ content.document.getElementById("slider").firstChild.remove()
+ )
+ );
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_node_tabbing_order_highlighter.js b/devtools/server/tests/browser/browser_accessibility_node_tabbing_order_highlighter.js
new file mode 100644
index 0000000000..adb47c0ec6
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_node_tabbing_order_highlighter.js
@@ -0,0 +1,92 @@
+/* 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";
+
+// Checks for the NodeTabbingOrderHighlighter.
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: MAIN_DOMAIN + "doc_accessibility_infobar.html",
+ },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ NodeTabbingOrderHighlighter,
+ } = require("resource://devtools/server/actors/highlighters/node-tabbing-order.js");
+
+ // Checks for updated content for an infobar.
+ async function testShowHide(highlighter, node, index) {
+ const shown = highlighter.show(node, { index });
+ const infoBarText = highlighter.getElement("infobar-text");
+
+ ok(shown, "Highlighter is shown.");
+ is(
+ parseInt(infoBarText.getTextContent(), 10),
+ index,
+ "infobar text content is correct"
+ );
+
+ highlighter.hide();
+ }
+
+ // Start testing. First, create highlighter environment and initialize.
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(content.window);
+
+ // Wait for loading highlighter environment content to complete before
+ // creating the highlighter.
+ await new Promise(resolve => {
+ const doc = env.document;
+
+ function onContentLoaded() {
+ if (
+ doc.readyState === "interactive" ||
+ doc.readyState === "complete"
+ ) {
+ resolve();
+ } else {
+ doc.addEventListener("DOMContentLoaded", onContentLoaded, {
+ once: true,
+ });
+ }
+ }
+
+ onContentLoaded();
+ });
+
+ // Now, we can test the Infobar's index content.
+ const node = content.document.createElement("div");
+ content.document.body.append(node);
+ const highlighter = new NodeTabbingOrderHighlighter(env);
+ await highlighter.isReady;
+
+ info("Showing Node tabbing order highlighter with index");
+ await testShowHide(highlighter, node, 1);
+
+ info("Showing Node tabbing order highlighter with new index");
+ await testShowHide(highlighter, node, 9);
+
+ info(
+ "Showing and highlighting focused node with the Node tabbing order highlighter"
+ );
+ highlighter.show(node, { index: 1 });
+ highlighter.updateFocus(true);
+ const { classList } = highlighter.getElement("root");
+ ok(classList.contains("focused"), "Focus styling is applied");
+ highlighter.updateFocus(false);
+ ok(!classList.contains("focused"), "Focus styling is removed");
+ highlighter.hide();
+ });
+ }
+ );
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_simple.js b/devtools/server/tests/browser/browser_accessibility_simple.js
new file mode 100644
index 0000000000..518d4dbb99
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_simple.js
@@ -0,0 +1,106 @@
+/* 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 PREF_ACCESSIBILITY_FORCE_DISABLED = "accessibility.force_disabled";
+
+function checkAccessibilityState(accessibility, parentAccessibility, expected) {
+ const { enabled } = accessibility;
+ const { canBeDisabled, canBeEnabled } = parentAccessibility;
+ is(enabled, expected.enabled, "Enabled state is correct.");
+ is(canBeDisabled, expected.canBeDisabled, "canBeDisabled state is correct.");
+ is(canBeEnabled, expected.canBeEnabled, "canBeEnabled state is correct.");
+}
+
+// Simple checks for the AccessibilityActor and AccessibleWalkerActor
+
+add_task(async function () {
+ const {
+ walker: domWalker,
+ target,
+ accessibility,
+ parentAccessibility,
+ a11yWalker,
+ } = await initAccessibilityFrontsForUrl(
+ "data:text/html;charset=utf-8,<title>test</title><div></div>",
+ { enableByDefault: false }
+ );
+
+ ok(accessibility, "The AccessibilityFront was created");
+ ok(accessibility.getWalker, "The getWalker method exists");
+ ok(accessibility.getSimulator, "The getSimulator method exists");
+
+ ok(accessibility.accessibleWalkerFront, "Accessible walker was initialized");
+
+ is(
+ a11yWalker,
+ accessibility.accessibleWalkerFront,
+ "The AccessibleWalkerFront was returned"
+ );
+
+ const a11ySimulator = accessibility.simulatorFront;
+ ok(accessibility.simulatorFront, "Accessible simulator was initialized");
+ is(
+ a11ySimulator,
+ accessibility.simulatorFront,
+ "The SimulatorFront was returned"
+ );
+
+ checkAccessibilityState(accessibility, parentAccessibility, {
+ enabled: false,
+ canBeDisabled: true,
+ canBeEnabled: true,
+ });
+
+ info("Force disable accessibility service: updates canBeEnabled flag");
+ let onEvent = parentAccessibility.once("can-be-enabled-change");
+ Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, 1);
+ await onEvent;
+ checkAccessibilityState(accessibility, parentAccessibility, {
+ enabled: false,
+ canBeDisabled: true,
+ canBeEnabled: false,
+ });
+
+ info("Clear force disable accessibility service: updates canBeEnabled flag");
+ onEvent = parentAccessibility.once("can-be-enabled-change");
+ Services.prefs.clearUserPref(PREF_ACCESSIBILITY_FORCE_DISABLED);
+ await onEvent;
+ checkAccessibilityState(accessibility, parentAccessibility, {
+ enabled: false,
+ canBeDisabled: true,
+ canBeEnabled: true,
+ });
+
+ info("Initialize accessibility service");
+ const initEvent = accessibility.once("init");
+ await parentAccessibility.enable();
+ await waitForA11yInit();
+ await initEvent;
+ checkAccessibilityState(accessibility, parentAccessibility, {
+ enabled: true,
+ canBeDisabled: true,
+ canBeEnabled: true,
+ });
+
+ const rootNode = await domWalker.getRootNode();
+ const a11yDoc = await accessibility.accessibleWalkerFront.getAccessibleFor(
+ rootNode
+ );
+ ok(a11yDoc, "Accessible document actor is created");
+
+ info("Shutdown accessibility service");
+ const shutdownEvent = accessibility.once("shutdown");
+ await waitForA11yShutdown(parentAccessibility);
+ await shutdownEvent;
+ checkAccessibilityState(accessibility, parentAccessibility, {
+ enabled: false,
+ canBeDisabled: true,
+ canBeEnabled: true,
+ });
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_simulator.js b/devtools/server/tests/browser/browser_accessibility_simulator.js
new file mode 100644
index 0000000000..47e3b898a3
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_simulator.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 {
+ accessibility: {
+ SIMULATION_TYPE: { PROTANOPIA },
+ },
+} = require("resource://devtools/shared/constants.js");
+const {
+ simulation: {
+ COLOR_TRANSFORMATION_MATRICES: {
+ PROTANOPIA: PROTANOPIA_MATRIX,
+ NONE: DEFAULT_MATRIX,
+ },
+ },
+} = require("resource://devtools/server/actors/accessibility/constants.js");
+
+// Checks for the SimulatorActor
+
+async function setup() {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.window.testColorMatrix = function (actual, expected) {
+ for (const idx in actual) {
+ is(
+ actual[idx].toFixed(3),
+ expected[idx].toFixed(3),
+ "Color matrix value is set correctly."
+ );
+ }
+ };
+ });
+ SimpleTest.registerCleanupFunction(async function () {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.window.testColorMatrix = null;
+ });
+ });
+}
+
+async function testSimulate(simulator, matrix, type = null) {
+ const matrixApplied = await simulator.simulate({ types: type ? [type] : [] });
+ ok(matrixApplied, "Simulation color matrix is successfully applied.");
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[type, matrix]],
+ ([simulationType, simulationMatrix]) => {
+ const { window } = content;
+ info(
+ `Test that color matrix is set to ${
+ simulationType || "default"
+ } simulation values.`
+ );
+ window.testColorMatrix(
+ window.docShell.getColorMatrix(),
+ simulationMatrix
+ );
+ }
+ );
+}
+
+add_task(async function () {
+ const { target, accessibility } = await initAccessibilityFrontsForUrl(
+ MAIN_DOMAIN + "doc_accessibility.html",
+ { enableByDefault: false }
+ );
+
+ const simulator = accessibility.simulatorFront;
+ if (!simulator) {
+ ok(false, "Missing simulator actor.");
+ return;
+ }
+
+ await setup();
+
+ info("Test that protanopia is successfully simulated.");
+ await testSimulate(simulator, PROTANOPIA_MATRIX, PROTANOPIA);
+
+ info(
+ "Test that simulations are successfully removed by setting default color matrix."
+ );
+ await testSimulate(simulator, DEFAULT_MATRIX);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_tabbing_order_highlighter.js b/devtools/server/tests/browser/browser_accessibility_tabbing_order_highlighter.js
new file mode 100644
index 0000000000..fb99534318
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_tabbing_order_highlighter.js
@@ -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/. */
+
+"use strict";
+
+// Checks for the TabbingOrderHighlighter.
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: MAIN_DOMAIN + "doc_accessibility_infobar.html",
+ },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ TabbingOrderHighlighter,
+ } = require("resource://devtools/server/actors/highlighters/tabbing-order.js");
+
+ // Start testing. First, create highlighter environment and initialize.
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(content.window);
+
+ // Wait for loading highlighter environment content to complete before
+ // creating the highlighter.
+ await new Promise(resolve => {
+ const doc = env.document;
+
+ function onContentLoaded() {
+ if (
+ doc.readyState === "interactive" ||
+ doc.readyState === "complete"
+ ) {
+ resolve();
+ } else {
+ doc.addEventListener("DOMContentLoaded", onContentLoaded, {
+ once: true,
+ });
+ }
+ }
+
+ onContentLoaded();
+ });
+
+ // Now, we can test the Infobar's index content.
+ const node = content.document.createElement("div");
+ content.document.body.append(node);
+ const highlighter = new TabbingOrderHighlighter(env);
+ await highlighter.isReady;
+
+ info("Showing tabbing order highlighter for all tabbable nodes");
+ const { contentDOMReference, index } = await highlighter.show(
+ content.document,
+ {
+ index: 0,
+ }
+ );
+
+ is(
+ contentDOMReference,
+ null,
+ "No current element when at the end of the tab order"
+ );
+ is(index, 2, "Current index is correct");
+ is(
+ highlighter._highlighters.size,
+ 2,
+ "Number of node tabbing order highlighters is correct"
+ );
+ for (let i = 0; i < highlighter._highlighters.size; i++) {
+ const nodeHighlighter = [...highlighter._highlighters.values()][i];
+ const infoBarText = nodeHighlighter.getElement("infobar-text");
+
+ is(
+ parseInt(infoBarText.getTextContent(), 10),
+ i + 1,
+ "infobar text content is correct"
+ );
+ }
+
+ info("Showing focus highlighting");
+ const input = content.document.getElementById("input");
+ highlighter.updateFocus({ node: input, focused: true });
+ const nodeHighlighter = highlighter._highlighters.get(input);
+ const { classList } = nodeHighlighter.getElement("root");
+ ok(classList.contains("focused"), "Focus styling is applied");
+ highlighter.updateFocus({ node: input, focused: false });
+ ok(!classList.contains("focused"), "Focus styling is removed");
+
+ highlighter.hide();
+ });
+ }
+ );
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_text_label_audit.js b/devtools/server/tests/browser/browser_accessibility_text_label_audit.js
new file mode 100644
index 0000000000..55afbdf936
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_text_label_audit.js
@@ -0,0 +1,1134 @@
+/* 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";
+
+/**
+ * Checks functionality around text label audit for the AccessibleActor.
+ */
+
+const {
+ accessibility: {
+ AUDIT_TYPE: { TEXT_LABEL },
+ SCORES: { BEST_PRACTICES, FAIL, WARNING },
+ ISSUE_TYPE: {
+ [TEXT_LABEL]: {
+ DIALOG_NO_NAME,
+ DOCUMENT_NO_TITLE,
+ EMBED_NO_NAME,
+ FIGURE_NO_NAME,
+ FORM_FIELDSET_NO_NAME,
+ FORM_FIELDSET_NO_NAME_FROM_LEGEND,
+ FORM_NO_NAME,
+ FORM_NO_VISIBLE_NAME,
+ FORM_OPTGROUP_NO_NAME_FROM_LABEL,
+ HEADING_NO_CONTENT,
+ HEADING_NO_NAME,
+ IFRAME_NO_NAME_FROM_TITLE,
+ IMAGE_NO_NAME,
+ INTERACTIVE_NO_NAME,
+ MATHML_GLYPH_NO_NAME,
+ TOOLBAR_NO_NAME,
+ },
+ },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+add_task(async function () {
+ const { target, walker, a11yWalker, parentAccessibility } =
+ await initAccessibilityFrontsForUrl(
+ `${MAIN_DOMAIN}doc_accessibility_text_label_audit.html`
+ );
+
+ const tests = [
+ ["Button menu with inner content", "#buttonmenu-1", null],
+ ["Button menu nested inside a <label>", "#buttonmenu-2", null],
+ [
+ "Button menu with no name",
+ "#buttonmenu-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Button menu with aria-label", "#buttonmenu-4", null],
+ ["Button menu with <label>", "#buttonmenu-5", null],
+ ["Button menu with aria-labelledby", "#buttonmenu-6", null],
+ ["Paragraph with inner content", "#p1", null],
+ ["Empty paragraph", "#p2", null],
+ [
+ "<canvas> with no name",
+ "#canvas-1",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["<canvas> with aria-label", "#canvas-2", null],
+ ["<canvas> with aria-labelledby", "#canvas-3", null],
+ [
+ "<canvas> with inner content",
+ "#canvas-4",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "Checkbox with no name",
+ "#checkbox-1",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Checkbox with unrelated label",
+ "#checkbox-2",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["Checkbox nested inside a <label>", "#checkbox-3", null],
+ ["Checkbox with a label", "#checkbox-4", null],
+ [
+ "Checkbox with aria-label",
+ "#checkbox-5",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ ["Checkbox with aria-labelledby visible label", "#checkbox-6", null],
+ [
+ "Empty aria checkbox",
+ "#checkbox-7",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria checkbox with aria-label",
+ "#checkbox-8",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ ["Aria checkbox with aria-labelledby visible label", "#checkbox-9", null],
+ ["Menuitem checkbox with inner content", "#menuitemcheckbox-1", null],
+ [
+ "Menuitem checkbox with unlabelled inner content",
+ "#menuitemcheckbox-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Empty menuitem checkbox",
+ "#menuitemcheckbox-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Menuitem checkbox with no textual inner content",
+ "#menuitemcheckbox-4",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Menuitem checkbox with labelled inner content",
+ "#menuitemcheckbox-5",
+ null,
+ ],
+ [
+ "Menuitem checkbox with white space inner content",
+ "#menuitemcheckbox-6",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Column header with inner content", "#columnheader-1", null],
+ [
+ "Empty column header",
+ "#columnheader-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Column header with white space inner content",
+ "#columnheader-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Column header with aria-label", "#columnheader-4", null],
+ [
+ "Column header with empty aria-label",
+ "#columnheader-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Column header with white space aria-label",
+ "#columnheader-6",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Column header with aria-labelledby", "#columnheader-7", null],
+ ["Aria column header with inner content", "#columnheader-8", null],
+ [
+ "Empty aria column header",
+ "#columnheader-9",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria column header with white space inner content",
+ "#columnheader-10",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria column header with aria-label", "#columnheader-11", null],
+ [
+ "Aria column header with empty aria-label",
+ "#columnheader-12",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria column header with white space aria-label",
+ "#columnheader-13",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria column header with aria-labelledby", "#columnheader-14", null],
+ ["Combobox with a <label>", "#combobox-1", null],
+ [
+ "Combobox with no label",
+ "#combobox-2",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Combobox with unrelated label",
+ "#combobox-3",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["Combobox nested inside a label", "#combobox-4", null],
+ [
+ "Combobox with aria-label",
+ "#combobox-5",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ ["Combobox with aria-labelledby a visible label", "#combobox-6", null],
+ ["Combobox option with inner content", "#combobox-option-1", null],
+ [
+ "Combobox option with no inner content",
+ "#combobox-option-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Combobox option with white string inner content",
+ "#combobox-option-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Combobox option with label attribute", "#combobox-option-4", null],
+ [
+ "Combobox option with empty label attribute",
+ "#combobox-option-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Combobox option with white string label attribute",
+ "#combobox-option-6",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Svg diagram with no name",
+ "#diagram-1",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "Svg diagram with empty aria-label",
+ "#diagram-2",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["Svg diagram with aria-label", "#diagram-3", null],
+ ["Svg diagram with aria-labelledby", "#diagram-4", null],
+ [
+ "Svg diagram with aria-labelledby an element with empty content",
+ "#diagram-5",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "Dialog with no name",
+ "#dialog-1",
+ { score: BEST_PRACTICES, issue: DIALOG_NO_NAME },
+ ],
+ [
+ "Dialog with empty aria-label",
+ "#dialog-2",
+ { score: BEST_PRACTICES, issue: DIALOG_NO_NAME },
+ ],
+ ["Dialog with aria-label", "#dialog-3", null],
+ ["Dialog with aria-labelledby", "#dialog-4", null],
+ [
+ "Aria dialog with no name",
+ "#dialog-5",
+ { score: BEST_PRACTICES, issue: DIALOG_NO_NAME },
+ ],
+ [
+ "Aria dialog with empty aria-label",
+ "#dialog-6",
+ { score: BEST_PRACTICES, issue: DIALOG_NO_NAME },
+ ],
+ ["Aria dialog with aria-label", "#dialog-7", null],
+ ["Aria dialog with aria-labelledby", "#dialog-8", null],
+ [
+ "Dialog with aria-labelledby an element with empty content",
+ "#dialog-9",
+ { score: BEST_PRACTICES, issue: DIALOG_NO_NAME },
+ ],
+ [
+ "Aria dialog with aria-labelledby an element with empty content",
+ "#dialog-10",
+ { score: BEST_PRACTICES, issue: DIALOG_NO_NAME },
+ ],
+ [
+ "Edit combobox with no name",
+ "#editcombobox-1",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Edit combobox with aria-label",
+ "#editcombobox-2",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Edit combobox with aria-labelled a visible label",
+ "#editcombobox-3",
+ null,
+ ],
+ ["Input nested inside a <label>", "#entry-1", null],
+ ["Input with no name", "#entry-2", { score: FAIL, issue: FORM_NO_NAME }],
+ [
+ "Input with aria-label",
+ "#entry-3",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Input with unrelated <label>",
+ "#entry-4",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["Input with <label>", "#entry-5", null],
+ ["Input with aria-labelledby", "#entry-6", null],
+ [
+ "Aria textbox with no name",
+ "#entry-7",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria textbox with aria-label",
+ "#entry-8",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ ["Aria textbox with aria-labelledby", "#entry-9", null],
+ ["Figure with <figcaption>", "#figure-1", null],
+ [
+ "Figore with no <figcaption>",
+ "#figure-2",
+ { score: BEST_PRACTICES, issue: FIGURE_NO_NAME },
+ ],
+ ["Aria figure with aria-labelledby", "#figure-3", null],
+ [
+ "Aria figure with aria-labelledby an element with empty content",
+ "#figure-4",
+ { score: BEST_PRACTICES, issue: FIGURE_NO_NAME },
+ ],
+ [
+ "Aria figure with no name",
+ "#figure-5",
+ { score: BEST_PRACTICES, issue: FIGURE_NO_NAME },
+ ],
+ ["Image with no alt text", "#img-1", { score: FAIL, issue: IMAGE_NO_NAME }],
+ ["Image with aria-label", "#img-2", null],
+ ["Image with aria-labelledby", "#img-3", null],
+ ["Image with alt text", "#img-4", null],
+ [
+ "Image with aria-labelledby an element with empty content",
+ "#img-5",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "Aria image with no name",
+ "#img-6",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["Aria image with aria-label", "#img-7", null],
+ ["Aria image with aria-labelledby", "#img-8", null],
+ [
+ "Aria image with empty aria-label",
+ "#img-9",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "Aria image with aria-labelledby an element with empty content",
+ "#img-10",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["<optgroup> with label", "#optgroup-1", null],
+ [
+ "<optgroup> with empty label",
+ "#optgroup-2",
+ { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL },
+ ],
+ [
+ "<optgroup> with no label",
+ "#optgroup-3",
+ { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL },
+ ],
+ [
+ "<optgroup> with aria-label",
+ "#optgroup-4",
+ { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL },
+ ],
+ [
+ "<optgroup> with aria-labelledby",
+ "#optgroup-5",
+ { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL },
+ ],
+ ["<fieldset> with <legend>", "#fieldset-1", null],
+ [
+ "<fieldset> with empty <legend>",
+ "#fieldset-2",
+ { score: FAIL, issue: FORM_FIELDSET_NO_NAME },
+ ],
+ [
+ "<fieldset> with no <legend>",
+ "#fieldset-3",
+ { score: FAIL, issue: FORM_FIELDSET_NO_NAME },
+ ],
+ [
+ "<fieldset> with aria-label",
+ "#fieldset-4",
+ { score: WARNING, issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND },
+ ],
+ [
+ "<fieldset> with aria-labelledby",
+ "#fieldset-5",
+ { score: WARNING, issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND },
+ ],
+ ["Empty <h1>", "#heading-1", { score: FAIL, issue: HEADING_NO_NAME }],
+ ["<h1> with inner content", "#heading-2", null],
+ [
+ "<h1> with white space inner content",
+ "#heading-3",
+ { score: FAIL, issue: HEADING_NO_NAME },
+ ],
+ [
+ "<h1> with aria-label",
+ "#heading-4",
+ { score: WARNING, issue: HEADING_NO_CONTENT },
+ ],
+ [
+ "<h1> with aria-labelledby",
+ "#heading-5",
+ { score: WARNING, issue: HEADING_NO_CONTENT },
+ ],
+ ["<h1> with inner content and aria-label", "#heading-6", null],
+ ["<h1> with inner content and aria-labelledby", "#heading-7", null],
+ [
+ "Empty aria heading",
+ "#heading-8",
+ { score: FAIL, issue: HEADING_NO_NAME },
+ ],
+ ["Aria heading with content", "#heading-9", null],
+ [
+ "Aria heading with white space inner content",
+ "#heading-10",
+ { score: FAIL, issue: HEADING_NO_NAME },
+ ],
+ [
+ "Aria heading with aria-label",
+ "#heading-11",
+ { score: WARNING, issue: HEADING_NO_CONTENT },
+ ],
+ [
+ "Aria heading with aria-labelledby",
+ "#heading-12",
+ { score: WARNING, issue: HEADING_NO_CONTENT },
+ ],
+ ["Aria heading with inner content and aria-label", "#heading-13", null],
+ [
+ "Aria heading with inner content and aria-labelledby",
+ "#heading-14",
+ null,
+ ],
+ [
+ "Image map with no name",
+ "#imagemap-1",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["Image map with aria-label", "#imagemap-2", null],
+ ["Image map with aria-labelledby", "#imagemap-3", null],
+ ["Image map with alt attribute", "#imagemap-4", null],
+ [
+ "Image map with aria-labelledby an element with empty content",
+ "#imagemap-5",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["<iframe> with title", "#iframe-1", null],
+ [
+ "<iframe> with empty title",
+ "#iframe-2",
+ { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE },
+ ],
+ [
+ "<iframe> with no title",
+ "#iframe-3",
+ { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE },
+ ],
+ [
+ "<iframe> with aria-label",
+ "#iframe-4",
+ { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE },
+ ],
+ [
+ "<iframe> with aria-label and title",
+ "#iframe-5",
+ { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE },
+ ],
+ [
+ "<object> with image data type and no name",
+ "#object-1",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["<object> with image data type and aria-label", "#object-2", null],
+ ["<object> with image data type and aria-labelledby", "#object-3", null],
+ ["<object> with non-image data type", "#object-4", null],
+ [
+ "<embed> with image data type and no name",
+ "#embed-1",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "<embed> with video data type and no name",
+ "#embed-2",
+ { score: FAIL, issue: EMBED_NO_NAME },
+ ],
+ ["<embed> with video data type and aria-label", "#embed-3", null],
+ ["<embed> with video data type and aria-labelledby", "#embed-4", null],
+ ["Link with no inner content", "#link-1", null],
+ ["Link with inner content", "#link-2", null],
+ [
+ "Link with href and no inner content",
+ "#link-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Link with href and inner content", "#link-4", null],
+ [
+ "Link with empty href and no inner content",
+ "#link-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Link with empty href and inner content", "#link-6", null],
+ [
+ "Link with # href and no inner content",
+ "#link-7",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Link with # href and inner content", "#link-8", null],
+ [
+ "Link with non empty href and no inner content",
+ "#link-9",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Link with non empty href and inner content", "#link-10", null],
+ ["Link with aria-label", "#link-11", null],
+ ["Link with aria-labelledby", "#link-12", null],
+ [
+ "Aria link with no inner content",
+ "#link-13",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria link with inner content", "#link-14", null],
+ ["Aria link with aria-label", "#link-15", null],
+ ["Aria link with aria-labelledby", "#link-16", null],
+ ["<select> with a visible <label>", "#listbox-1", null],
+ [
+ "<select> with no name",
+ "#listbox-2",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "<select> with unrelated <label>",
+ "#listbox-3",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["<select> nested inside a <label>", "#listbox-4", null],
+ [
+ "<select> with aria-label",
+ "#listbox-5",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ ["<select> with aria-labelledby a visible element", "#listbox-6", null],
+ [
+ "MathML glyph with no name",
+ "#mglyph-1",
+ { score: FAIL, issue: MATHML_GLYPH_NO_NAME },
+ ],
+ ["MathML glyph with aria-label", "#mglyph-2", null],
+ ["MathML glyph with aria-labelledby", "#mglyph-3", null],
+ ["MathML glyph with alt text", "#mglyph-4", null],
+ [
+ "MathML glyph with empty alt text",
+ "#mglyph-5",
+ { score: FAIL, issue: MATHML_GLYPH_NO_NAME },
+ ],
+ [
+ "MathML glyph with aria-labelledby an element with no inner content",
+ "#mglyph-6",
+ { score: FAIL, issue: MATHML_GLYPH_NO_NAME },
+ ],
+ [
+ "Aria menu item with no name",
+ "#menuitem-1",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria menu item with empty aria-label",
+ "#menuitem-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria menu item with aria-label", "#menuitem-3", null],
+ ["Aria menu item with aria-labelledby", "#menuitem-4", null],
+ [
+ "Aria menu item with aria-labelledby element with empty inner content",
+ "#menuitem-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria menu item with inner content", "#menuitem-6", null],
+ ["Option with inner content", "#option-1", null],
+ [
+ "Option with no inner content",
+ "#option-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Option with white space inner ",
+ "#option-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Option with a label", "#option-4", null],
+ [
+ "Option with an empty label",
+ "#option-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Option with a white space label",
+ "#option-6",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria option with inner content", "#option-7", null],
+ [
+ "Aria option with no inner content",
+ "#option-8",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria option with white space inner content",
+ "#option-9",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria option with aria-label", "#option-10", null],
+ [
+ "Aria option with empty aria-label",
+ "#option-11",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria option with white space aria-label",
+ "#option-12",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria option with aria-labelledby", "#option-13", null],
+ [
+ "Aria option with aria-labelledby an element with empty content",
+ "#option-14",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria option with aria-labelledby an element with white space content",
+ "#option-15",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Empty aria treeitem",
+ "#treeitem-1",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria treeitem with empty aria-label",
+ "#treeitem-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria treeitem with aria-label", "#treeitem-3", null],
+ ["Aria treeitem with aria-labelledby", "#treeitem-4", null],
+ [
+ "Aria treeitem with aria-labelledby an element with empty content",
+ "#treeitem-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria treeitem with inner content", "#treeitem-6", null],
+ [
+ "Aria tab with no content",
+ "#tab-1",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria tab with empty aria-label",
+ "#tab-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria tab with aria-label", "#tab-3", null],
+ ["Aria tab with aria-labelledby", "#tab-4", null],
+ [
+ "Aria tab with aria-labelledby an element with empty content",
+ "#tab-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria tab with inner content", "#tab-6", null],
+ ["Password nested inside a <label>", "#password-1", null],
+ ["Password no name", "#password-2", { score: FAIL, issue: FORM_NO_NAME }],
+ [
+ "Password with aria-label",
+ "#password-3",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Password with unrelated label",
+ "#password-4",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["Password with <label>", "#password-5", null],
+ ["Password with aria-labelledby a visible element", "#password-6", null],
+ ["<progress> nested inside a label", "#progress-1", null],
+ [
+ "<progress> with no name",
+ "#progress-2",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "<progress> with aria-label",
+ "#progress-3",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "<progress> with unrelated <label>",
+ "#progress-4",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["<progress> with <label>", "#progress-5", null],
+ ["<progress> with aria-labelledby a visible element", "#progress-6", null],
+ [
+ "Aria progressbar nested inside a <label>",
+ "#progress-7",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria progressbar with aria-labelledby a visible element",
+ "#progress-8",
+ null,
+ ],
+ [
+ "Aria progressbar no name",
+ "#progress-9",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria progressbar with aria-label",
+ "#progress-10",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Aria progressbar with unrelated <label>",
+ "#progress-11",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria progressbar with <label>",
+ "#progress-12",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria progressbar with aria-labelledby a visible <label>",
+ "#progress-13",
+ null,
+ ],
+ ["Button with inner content", "#button-1", null],
+ [
+ "Image button with no name",
+ "#button-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Button with no name",
+ "#button-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Image button with empty alt text",
+ "#button-4",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Image button with alt text", "#button-5", null],
+ [
+ "Button with white space inner content",
+ "#button-6",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Button inside a <label>", "#button-7", null],
+ ["Button with aria-label", "#button-8", null],
+ [
+ "Button with unrelated <label>",
+ "#button-9",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Button with <label>", "#button-10", null],
+ ["Button with aria-labelledby a visile <label>", "#button-11", null],
+ [
+ "Aria button inside a label",
+ "#button-12",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria button with aria-labelled by a <label>", "#button-13", null],
+ [
+ "Aria button with no content",
+ "#button-14",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria button with aria-label", "#button-15", null],
+ [
+ "Aria button with unrelated <label>",
+ "#button-16",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria button with <label>",
+ "#button-17",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria button with aria-labelledby a visible <label>", "#button-18", null],
+ ["Radio nested inside a label", "#radiobutton-1", null],
+ [
+ "Radio with no name",
+ "#radiobutton-2",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Radio with aria-label",
+ "#radiobutton-3",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Radio with unrelated <label>",
+ "#radiobutton-4",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["Radio with visible label>", "#radiobutton-5", null],
+ ["Radio with aria-labelledby a visible <label>", "#radiobutton-6", null],
+ [
+ "Aria radio with no name",
+ "#radiobutton-7",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria radio with aria-label",
+ "#radiobutton-8",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Aria radio with aria-labelledby a visible element",
+ "#radiobutton-9",
+ null,
+ ],
+ ["Aria menuitemradio with inner content", "#menuitemradio-1", null],
+ [
+ "Aria menuitemradio with no inner content",
+ "#menuitemradio-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria menuitemradio with white space inner content",
+ "#menuitemradio-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Rowheader with inner content", "#rowheader-1", null],
+ [
+ "Rowheader with no inner content",
+ "#rowheader-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Rowheader with white space inner content",
+ "#rowheader-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Rowheader with aria-label", "#rowheader-4", null],
+ [
+ "Rowheader with empty aria-label",
+ "#rowheader-5",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Rowheader with white space aria-label",
+ "#rowheader-6",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Rowheader with aria-labelledby", "#rowheader-7", null],
+ ["Aria rowheader with inner content", "#rowheader-8", null],
+ [
+ "Aria rowheader with no inner content",
+ "#rowheader-9",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria rowheader with white space inner content",
+ "#rowheader-10",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria rowheader with aria-label", "#rowheader-11", null],
+ [
+ "Aria rowheader with empty aria-label",
+ "#rowheader-12",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria rowheader with white space aria-label",
+ "#rowheader-13",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria rowheader with aria-labelledby", "#rowheader-14", null],
+ ["Slider nested inside a <label>", "#slider-1", null],
+ ["Slider with no name", "#slider-2", { score: FAIL, issue: FORM_NO_NAME }],
+ [
+ "Slider with aria-label",
+ "#slider-3",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Slider with unrelated <label>",
+ "#slider-4",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["Slider with a visible <label>", "#slider-5", null],
+ ["Slider with aria-labelled by a visible <label>", "#slider-6", null],
+ [
+ "Aria slider with no name",
+ "#slider-7",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria slider with aria-label",
+ "#slider-8",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ ["Aria slider with aria-labelledby a visible element", "#slider-9", null],
+ ["Number input inside a label", "#spinbutton-1", null],
+ [
+ "Number input with no label",
+ "#spinbutton-2",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Number input with aria-label",
+ "#spinbutton-3",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Number input with unrelated <label>",
+ "#spinbutton-4",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ ["Number input with visible <label>", "#spinbutton-5", null],
+ [
+ "Number input with aria-labelled by a visible <label>",
+ "#spinbutton-6",
+ null,
+ ],
+ [
+ "Aria spinbutton with no name",
+ "#spinbutton-7",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria spinbutton with aria-label",
+ "#spinbutton-8",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ [
+ "Aria spinbutton with aria-labelledby a visible element",
+ "#spinbutton-9",
+ null,
+ ],
+ [
+ "Aria switch with no name",
+ "#switch-1",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria switch wtih aria-label",
+ "#switch-2",
+ { score: WARNING, issue: FORM_NO_VISIBLE_NAME },
+ ],
+ ["Aria switch with aria-labelledby a visible element", "#switch-3", null],
+ [
+ "Aria switch with unrelated <label>",
+ "#switch-4",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ [
+ "Aria switch nested inside a <label>",
+ "#switch-5",
+ { score: FAIL, issue: FORM_NO_NAME },
+ ],
+ // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770
+ // ["Meter inside a label", "#meter-1", null],
+ // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770
+ // ["Meter with no name", "#meter-2", { score: FAIL, issue: FORM_NO_NAME }],
+ // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770
+ // ["Meter with aria-label", "#meter-3",
+ // { score: WARNING, issue: FORM_NO_VISIBLE_NAME}],
+ // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770
+ // ["Meter with unrelated <label>", "#meter-4", { score: FAIL, issue: FORM_NO_NAME }],
+ ["Meter with visible <label>", "#meter-5", null],
+ ["Meter with aria-labelledby a visible <label>", "#meter-6", null],
+ // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770
+ // ["Aria meter with no name", "#meter-7", { score: FAIL, issue: FORM_NO_NAME }],
+ // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770
+ // ["Aria meter with aria-label", "#meter-8",
+ // { score: WARNING, issue: FORM_NO_VISIBLE_NAME}],
+ ["Aria meter with aria-labelledby a visible element", "#meter-9", null],
+ ["Toggle button with inner content", "#togglebutton-1", null],
+ [
+ "Image toggle button with no name",
+ "#togglebutton-2",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Empty toggle button",
+ "#togglebutton-3",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Image toggle button with empty alt text",
+ "#togglebutton-4",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Image toggle button with alt text", "#togglebutton-5", null],
+ [
+ "Toggle button with white space inner content",
+ "#togglebutton-6",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Toggle button nested inside a label", "#togglebutton-7", null],
+ ["Toggle button with aria-label", "#togglebutton-8", null],
+ [
+ "Toggle button with unrelated <label>",
+ "#togglebutton-9",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Toggle button with <label>", "#togglebutton-10", null],
+ [
+ "Toggle button with aria-labelled by a visible <label>",
+ "#togglebutton-11",
+ null,
+ ],
+ [
+ "Aria toggle button nested inside a label",
+ "#togglebutton-12",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria toggle button with aria-labelled by and nested inside a label",
+ "#togglebutton-13",
+ null,
+ ],
+ [
+ "Aria toggle button with no name",
+ "#togglebutton-14",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ ["Aria toggle button with aria-label", "#togglebutton-15", null],
+ [
+ "Aria toggle button with unrelated <label>",
+ "#togglebutton-16",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria toggle button with <label>",
+ "#togglebutton-17",
+ { score: FAIL, issue: INTERACTIVE_NO_NAME },
+ ],
+ [
+ "Aria toggle button with aria-labelledby a visible <label>",
+ "#togglebutton-18",
+ null,
+ ],
+ ["Non-unique aria toolbar with aria-label", "#toolbar-1", null],
+ [
+ "Non-unique aria toolbar with no name (",
+ "#toolbar-2",
+ { score: FAIL, issue: TOOLBAR_NO_NAME },
+ ],
+ [
+ "Non-unique aAria toolbar with aria-labelledby an element with empty content",
+ "#toolbar-3",
+ { score: FAIL, issue: TOOLBAR_NO_NAME },
+ ],
+ ["Non-unique aria toolbar with aria-labelledby", "#toolbar-4", null],
+ ["SVGElement with role=img that has a title", "#svg-1", null],
+ ["SVGElement without role=img that has a title", "#svg-2", null],
+ [
+ "SVGElement with role=img and no name",
+ "#svg-3",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "SVGElement with no name",
+ "#svg-4",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ ["SVGElement with a name", "#svg-5", null],
+ [
+ "SVGElement with a name and with ownerSVGElement with a name",
+ "#svg-6",
+ null,
+ ],
+ ["SVGElement with a title", "#svg-7", null],
+ [
+ "SVGElement with a name and with ownerSVGElement with a title",
+ "#svg-8",
+ null,
+ ],
+ ["SVGElement with role=img that has a title", "#svg-9", null],
+ [
+ "SVGElement with a name and with ownerSVGElement with role=img that has a title",
+ "#svg-10",
+ null,
+ ],
+ [
+ "SVGElement with role=img and no title",
+ "#svg-11",
+ { score: FAIL, issue: IMAGE_NO_NAME },
+ ],
+ [
+ "SVGElement with a name and with ownerSVGElement with role=img and no title",
+ "#svg-12",
+ null,
+ ],
+ ];
+
+ for (const [description, selector, expected] of tests) {
+ info(description);
+ const node = await walker.querySelector(walker.rootNode, selector);
+ const front = await a11yWalker.getAccessibleFor(node);
+ const audit = await front.audit({ types: [TEXT_LABEL] });
+ Assert.deepEqual(
+ audit[TEXT_LABEL],
+ expected,
+ `Audit result for ${selector} is correct.`
+ );
+ }
+
+ info("Test document rule:");
+ const front = await a11yWalker.getAccessibleFor(walker.rootNode);
+ let audit = await front.audit({ types: [TEXT_LABEL] });
+ info("Document with no title");
+ Assert.deepEqual(
+ audit[TEXT_LABEL],
+ { score: FAIL, issue: DOCUMENT_NO_TITLE },
+ "Audit result for document is correct."
+ );
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.document.title = "Hello world";
+ });
+ audit = await front.audit({ types: [TEXT_LABEL] });
+ info("Document with title");
+ Assert.deepEqual(
+ audit[TEXT_LABEL],
+ null,
+ "Audit result for document is correct."
+ );
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_text_label_audit_frame.js b/devtools/server/tests/browser/browser_accessibility_text_label_audit_frame.js
new file mode 100644
index 0000000000..fbd56cee60
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_text_label_audit_frame.js
@@ -0,0 +1,48 @@
+/* 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";
+
+/**
+ * Checks functionality around text label audit for the AccessibleActor that is
+ * created for frame elements.
+ */
+
+const {
+ accessibility: {
+ AUDIT_TYPE: { TEXT_LABEL },
+ SCORES: { FAIL },
+ ISSUE_TYPE: {
+ [TEXT_LABEL]: { FRAME_NO_NAME },
+ },
+ },
+} = require("resource://devtools/shared/constants.js");
+
+add_task(async function () {
+ const { target, walker, a11yWalker, parentAccessibility } =
+ await initAccessibilityFrontsForUrl(
+ `${MAIN_DOMAIN}doc_accessibility_text_label_audit_frame.html`
+ );
+
+ const tests = [
+ ["Frame with no name", "#frame-1", { score: FAIL, issue: FRAME_NO_NAME }],
+ ["Frame with aria-label", "#frame-2", null],
+ ];
+
+ for (const [description, selector, expected] of tests) {
+ info(description);
+ const node = await walker.querySelector(walker.rootNode, selector);
+ const front = await a11yWalker.getAccessibleFor(node);
+ const audit = await front.audit({ types: [TEXT_LABEL] });
+ Assert.deepEqual(
+ audit[TEXT_LABEL],
+ expected,
+ `Audit result for ${selector} is correct.`
+ );
+ }
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_walker.js b/devtools/server/tests/browser/browser_accessibility_walker.js
new file mode 100644
index 0000000000..282a49b19a
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_walker.js
@@ -0,0 +1,170 @@
+/* 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";
+
+// Checks for the AccessibleWalkerActor
+
+add_task(async function () {
+ const { target, walker, a11yWalker, parentAccessibility } =
+ await initAccessibilityFrontsForUrl(MAIN_DOMAIN + "doc_accessibility.html");
+
+ ok(a11yWalker, "The AccessibleWalkerFront was returned");
+ const rootNode = await walker.getRootNode();
+ const a11yDoc = await a11yWalker.getAccessibleFor(rootNode);
+ ok(a11yDoc, "The AccessibleFront for root doc is created");
+
+ const children = await a11yWalker.children();
+ is(
+ children.length,
+ 1,
+ "AccessibleWalker only has 1 child - root doc accessible"
+ );
+ is(
+ a11yDoc,
+ children[0],
+ "Root accessible must be AccessibleWalker's only child"
+ );
+
+ const buttonNode = await walker.querySelector(walker.rootNode, "#button");
+ const accessibleFront = await a11yWalker.getAccessibleFor(buttonNode);
+
+ checkA11yFront(accessibleFront, {
+ name: "Accessible Button",
+ role: "button",
+ });
+
+ const ancestry = await a11yWalker.getAncestry(accessibleFront);
+ is(ancestry.length, 1, "Button is a direct child of a root document.");
+ is(
+ ancestry[0].accessible,
+ a11yDoc,
+ "Button's only ancestor is a root document"
+ );
+ is(
+ ancestry[0].children.length,
+ 8,
+ "Root doc should have correct number of children"
+ );
+ ok(
+ ancestry[0].children.includes(accessibleFront),
+ "Button accessible front is in root doc's children"
+ );
+
+ const browser = gBrowser.selectedBrowser;
+
+ // Ensure name-change event is emitted by walker when cached accessible's name
+ // gets updated (via DOM manipularion).
+ await emitA11yEvent(
+ a11yWalker,
+ "name-change",
+ (front, parent) => {
+ checkA11yFront(front, { name: "Renamed" }, accessibleFront);
+ checkA11yFront(parent, {}, a11yDoc);
+ },
+ () =>
+ SpecialPowers.spawn(browser, [], () =>
+ content.document
+ .getElementById("button")
+ .setAttribute("aria-label", "Renamed")
+ )
+ );
+
+ // Ensure reorder event is emitted by walker when DOM tree changes.
+ let docChildren = await a11yDoc.children();
+ is(docChildren.length, 8, "Root doc should have correct number of children");
+
+ await emitA11yEvent(
+ a11yWalker,
+ "reorder",
+ front => checkA11yFront(front, {}, a11yDoc),
+ () =>
+ SpecialPowers.spawn(browser, [], () => {
+ const input = content.document.createElement("input");
+ input.type = "text";
+ input.title = "This is a tooltip";
+ input.value = "New input";
+ content.document.body.appendChild(input);
+ })
+ );
+
+ docChildren = await a11yDoc.children();
+ is(docChildren.length, 9, "Root doc should have correct number of children");
+
+ let shown = await a11yWalker.highlightAccessible(docChildren[0]);
+ ok(shown, "AccessibleHighlighter highlighted the node");
+
+ shown = await a11yWalker.highlightAccessible(a11yDoc);
+ ok(shown, "AccessibleHighlighter highlights the document correctly.");
+ await a11yWalker.unhighlight();
+
+ info("Checking AccessibleWalker picker functionality");
+ ok(a11yWalker.pick, "AccessibleWalker pick method exists");
+ ok(a11yWalker.pickAndFocus, "AccessibleWalker pickAndFocus method exists");
+ ok(a11yWalker.cancelPick, "AccessibleWalker cancelPick method exists");
+
+ let onPickerEvent = a11yWalker.once("picker-accessible-hovered");
+ await a11yWalker.pick();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#h1",
+ { type: "mousemove" },
+ browser
+ );
+ let acc = await onPickerEvent;
+ checkA11yFront(acc, { name: "Accessibility Test" }, docChildren[0]);
+
+ onPickerEvent = a11yWalker.once("picker-accessible-previewed");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#h1",
+ { shiftKey: true },
+ browser
+ );
+ acc = await onPickerEvent;
+ checkA11yFront(acc, { name: "Accessibility Test" }, docChildren[0]);
+
+ onPickerEvent = a11yWalker.once("picker-accessible-canceled");
+ await BrowserTestUtils.synthesizeKey(
+ "VK_ESCAPE",
+ { type: "keydown" },
+ browser
+ );
+ await onPickerEvent;
+
+ onPickerEvent = a11yWalker.once("picker-accessible-hovered");
+ await a11yWalker.pick();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#h1",
+ { type: "mousemove" },
+ browser
+ );
+ await onPickerEvent;
+
+ onPickerEvent = a11yWalker.once("picker-accessible-picked");
+ await BrowserTestUtils.synthesizeMouseAtCenter("#h1", {}, browser);
+ acc = await onPickerEvent;
+ checkA11yFront(acc, { name: "Accessibility Test" }, docChildren[0]);
+
+ await a11yWalker.cancelPick();
+
+ info("Checking tabbing order highlighter");
+ let { elm, index } = await a11yWalker.showTabbingOrder(rootNode, 0);
+ isnot(!!elm, "No current element when at the end of the tab order");
+ is(index, 3, "Current index is correct");
+ await a11yWalker.hideTabbingOrder();
+
+ ({ elm, index } = await a11yWalker.showTabbingOrder(buttonNode, 0));
+ isnot(!!elm, "No current element when at the end of the tab order");
+ is(index, 2, "Current index is correct");
+ await a11yWalker.hideTabbingOrder();
+
+ info(
+ "When targets follow the WindowGlobal lifecycle and handle only one document, " +
+ "only check that the panel refreshes correctly and emit its 'reloaded' event"
+ );
+ await reloadBrowser();
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_accessibility_walker_audit.js b/devtools/server/tests/browser/browser_accessibility_walker_audit.js
new file mode 100644
index 0000000000..289023043f
--- /dev/null
+++ b/devtools/server/tests/browser/browser_accessibility_walker_audit.js
@@ -0,0 +1,155 @@
+/* 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 {
+ accessibility: { AUDIT_TYPE, ISSUE_TYPE, SCORES },
+} = require("resource://devtools/shared/constants.js");
+
+// Checks for the AccessibleWalkerActor audit.
+add_task(async function () {
+ const { target, a11yWalker, parentAccessibility } =
+ await initAccessibilityFrontsForUrl(
+ MAIN_DOMAIN + "doc_accessibility_audit.html"
+ );
+
+ const accessibles = [
+ {
+ name: "",
+ role: "document",
+ childCount: 2,
+ checks: {
+ [AUDIT_TYPE.CONTRAST]: null,
+ [AUDIT_TYPE.KEYBOARD]: null,
+ [AUDIT_TYPE.TEXT_LABEL]: {
+ score: SCORES.FAIL,
+ issue: ISSUE_TYPE.DOCUMENT_NO_TITLE,
+ },
+ },
+ },
+ {
+ name:
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " +
+ "eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ role: "paragraph",
+ childCount: 1,
+ checks: {
+ [AUDIT_TYPE.CONTRAST]: null,
+ [AUDIT_TYPE.KEYBOARD]: null,
+ [AUDIT_TYPE.TEXT_LABEL]: null,
+ },
+ },
+ {
+ name:
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " +
+ "eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ role: "text leaf",
+ childCount: 0,
+ checks: {
+ [AUDIT_TYPE.CONTRAST]: {
+ value: 4.0,
+ color: [255, 0, 0, 1],
+ backgroundColor: [255, 255, 255, 1],
+ isLargeText: false,
+ score: SCORES.FAIL,
+ },
+ [AUDIT_TYPE.KEYBOARD]: null,
+ [AUDIT_TYPE.TEXT_LABEL]: null,
+ },
+ },
+ {
+ name: "",
+ role: "paragraph",
+ childCount: 1,
+ checks: {
+ [AUDIT_TYPE.CONTRAST]: null,
+ [AUDIT_TYPE.KEYBOARD]: null,
+ [AUDIT_TYPE.TEXT_LABEL]: null,
+ },
+ },
+ {
+ name: "Accessible Paragraph",
+ role: "text leaf",
+ childCount: 0,
+ checks: {
+ [AUDIT_TYPE.CONTRAST]: {
+ value: 4.0,
+ color: [255, 0, 0, 1],
+ backgroundColor: [255, 255, 255, 1],
+ isLargeText: false,
+ score: SCORES.FAIL,
+ },
+ [AUDIT_TYPE.KEYBOARD]: null,
+ [AUDIT_TYPE.TEXT_LABEL]: null,
+ },
+ },
+ ];
+ const total = accessibles.length;
+ const auditProgress = [
+ { total, percentage: 20, completed: 1 },
+ { total, percentage: 40, completed: 2 },
+ { total, percentage: 60, completed: 3 },
+ { total, percentage: 80, completed: 4 },
+ { total, percentage: 100, completed: 5 },
+ ];
+
+ function findAccessible(name, role) {
+ return accessibles.find(
+ accessible => accessible.name === name && accessible.role === role
+ );
+ }
+
+ async function checkWalkerAudit(walker, expectedSize, options) {
+ info("Checking AccessibleWalker audit functionality");
+ const expectedProgress = Array.from(auditProgress);
+ const ancestries = await new Promise((resolve, reject) => {
+ const auditEventHandler = ({ type, ancestries: response, progress }) => {
+ switch (type) {
+ case "error":
+ walker.off("audit-event", auditEventHandler);
+ reject();
+ break;
+ case "completed":
+ walker.off("audit-event", auditEventHandler);
+ resolve(response);
+ is(expectedProgress.length, 0, "All progress events fired");
+ break;
+ case "progress":
+ SimpleTest.isDeeply(
+ progress,
+ expectedProgress.shift(),
+ "Progress data is correct"
+ );
+ break;
+ default:
+ break;
+ }
+ };
+
+ walker.on("audit-event", auditEventHandler);
+ walker.startAudit(options);
+ });
+
+ is(ancestries.length, expectedSize, "The size of ancestries is correct");
+ for (const ancestry of ancestries) {
+ for (const { accessible, children } of ancestry) {
+ checkA11yFront(
+ accessible,
+ findAccessible(accessibles.name, accessibles.role)
+ );
+ for (const child of children) {
+ checkA11yFront(child, findAccessible(child.name, child.role));
+ }
+ }
+ }
+ }
+
+ await checkWalkerAudit(a11yWalker, 3);
+ await checkWalkerAudit(a11yWalker, 2, { types: [AUDIT_TYPE.CONTRAST] });
+
+ await waitForA11yShutdown(parentAccessibility);
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_actor_error.js b/devtools/server/tests/browser/browser_actor_error.js
new file mode 100644
index 0000000000..0c28d77cca
--- /dev/null
+++ b/devtools/server/tests/browser/browser_actor_error.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that clients can catch errors in actors.
+ */
+
+const ACTORS_URL =
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/error-actor.js";
+
+add_task(async function test_old_actor() {
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ ActorRegistry.registerModule(ACTORS_URL, {
+ prefix: "error",
+ constructor: "ErrorActor",
+ type: { global: true },
+ });
+
+ const transport = DevToolsServer.connectPipe();
+ const gClient = new DevToolsClient(transport);
+ await gClient.connect();
+
+ const { errorActor } = await gClient.mainRoot.rootForm;
+ ok(errorActor, "Found the error actor.");
+
+ await Assert.rejects(
+ gClient.request({ to: errorActor, type: "error" }),
+ err =>
+ err.error == "unknownError" &&
+ /error occurred while processing 'error/.test(err.message),
+ "The request should be rejected"
+ );
+
+ await gClient.close();
+});
+
+const TEST_ERRORS_ACTOR_URL =
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/test-errors-actor.js";
+add_task(async function test_protocoljs_actor() {
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ info("Register the new TestErrorsActor");
+ require(TEST_ERRORS_ACTOR_URL);
+ ActorRegistry.registerModule(TEST_ERRORS_ACTOR_URL, {
+ prefix: "testErrors",
+ constructor: "TestErrorsActor",
+ type: { global: true },
+ });
+
+ info("Create a DevTools client/server pair");
+ const transport = DevToolsServer.connectPipe();
+ const gClient = new DevToolsClient(transport);
+ await gClient.connect();
+
+ info("Retrieve a TestErrorsFront instance");
+ const testErrorsFront = await gClient.mainRoot.getFront("testErrors");
+ ok(testErrorsFront, "has a TestErrorsFront instance");
+
+ await Assert.rejects(testErrorsFront.throwsComponentsException(), e => {
+ return new RegExp(
+ `NS_ERROR_NOT_IMPLEMENTED from: ${testErrorsFront.actorID} ` +
+ `\\(${TEST_ERRORS_ACTOR_URL}:\\d+:\\d+\\)`
+ ).test(e.message);
+ });
+ await Assert.rejects(testErrorsFront.throwsException(), e => {
+ // Not asserting the specific error message here, as it changes depending
+ // on the channel.
+ return new RegExp(
+ `Protocol error \\(TypeError\\):.* from: ${testErrorsFront.actorID} ` +
+ `\\(${TEST_ERRORS_ACTOR_URL}:\\d+:\\d+\\)`
+ ).test(e.message);
+ });
+ await Assert.rejects(testErrorsFront.throwsJSError(), e => {
+ return new RegExp(
+ `Protocol error \\(Error\\): JSError from: ${testErrorsFront.actorID} ` +
+ `\\(${TEST_ERRORS_ACTOR_URL}:\\d+:\\d+\\)`
+ ).test(e.message);
+ });
+ await Assert.rejects(testErrorsFront.throwsString(), e => {
+ return new RegExp(`ErrorString from: ${testErrorsFront.actorID}`).test(
+ e.message
+ );
+ });
+ await Assert.rejects(testErrorsFront.throwsObject(), e => {
+ return new RegExp(`foo from: ${testErrorsFront.actorID}`).test(e.message);
+ });
+
+ await gClient.close();
+});
diff --git a/devtools/server/tests/browser/browser_animation_actor-lifetime.js b/devtools/server/tests/browser/browser_animation_actor-lifetime.js
new file mode 100644
index 0000000000..ef157d31fc
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_actor-lifetime.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for Bug 1247243
+
+add_task(async function () {
+ info("Setting up inspector and animation actors.");
+ const { animations, walker } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation-data.html"
+ );
+
+ info("Testing animated node actor");
+ const animatedNodeActor = await walker.querySelector(
+ walker.rootNode,
+ ".animated"
+ );
+ await animations.getAnimationPlayersForNode(animatedNodeActor);
+
+ await assertNumberOfAnimationActors(
+ 1,
+ "AnimationActor have 1 AnimationPlayerActors"
+ );
+
+ info("Testing AnimationPlayerActors release");
+ const stillNodeActor = await walker.querySelector(walker.rootNode, ".still");
+ await animations.getAnimationPlayersForNode(stillNodeActor);
+ await assertNumberOfAnimationActors(
+ 0,
+ "AnimationActor does not have any AnimationPlayerActors anymore"
+ );
+
+ info("Testing multi animated node actor");
+ const multiNodeActor = await walker.querySelector(walker.rootNode, ".multi");
+ await animations.getAnimationPlayersForNode(multiNodeActor);
+ await assertNumberOfAnimationActors(
+ 2,
+ "AnimationActor has now 2 AnimationPlayerActors"
+ );
+
+ info("Testing single animated node actor");
+ await animations.getAnimationPlayersForNode(animatedNodeActor);
+ await assertNumberOfAnimationActors(
+ 1,
+ "AnimationActor has only one AnimationPlayerActors"
+ );
+
+ info("Testing AnimationPlayerActors release again");
+ await animations.getAnimationPlayersForNode(stillNodeActor);
+ await assertNumberOfAnimationActors(
+ 0,
+ "AnimationActor does not have any AnimationPlayerActors anymore"
+ );
+
+ async function assertNumberOfAnimationActors(expected, message) {
+ const actors = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[animations.actorID]],
+ function (actorID) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ // Convert actorID to current compartment string otherwise
+ // searchAllConnectionsForActor is confused and won't find the actor.
+ actorID = String(actorID);
+ const animationActors =
+ DevToolsServer.searchAllConnectionsForActor(actorID);
+ if (!animationActors) {
+ return 0;
+ }
+ return animationActors.actors.length;
+ }
+ );
+ is(actors, expected, message);
+ }
+});
diff --git a/devtools/server/tests/browser/browser_animation_emitMutations.js b/devtools/server/tests/browser/browser_animation_emitMutations.js
new file mode 100644
index 0000000000..796418c937
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_emitMutations.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the AnimationsActor emits events about changed animations on a
+// node after getAnimationPlayersForNode was called on that node.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ info("Retrieve a non-animated node");
+ const node = await walker.querySelector(walker.rootNode, ".not-animated");
+
+ info("Retrieve the animation player for the node");
+ const players = await animations.getAnimationPlayersForNode(node);
+ is(players.length, 0, "The node has no animation players");
+
+ info("Listen for new animations");
+ let onMutations = once(animations, "mutations");
+
+ info("Add a couple of animation on the node");
+ await node.modifyAttributes([
+ { attributeName: "class", newValue: "multiple-animations" },
+ ]);
+ let changes = await onMutations;
+
+ ok(true, "The mutations event was emitted");
+ is(changes.length, 2, "There are 2 changes in the mutation event");
+ ok(
+ changes.every(({ type }) => type === "added"),
+ "Both changes are additions"
+ );
+
+ const names = changes.map(c => c.player.initialState.name).sort();
+ is(names[0], "glow", "The animation 'glow' was added");
+ is(names[1], "move", "The animation 'move' was added");
+
+ info("Store the 2 new players for comparing later");
+ const p1 = changes[0].player;
+ const p2 = changes[1].player;
+
+ info("Listen for removed animations");
+ onMutations = once(animations, "mutations");
+
+ info("Remove the animation css class on the node");
+ await node.modifyAttributes([
+ { attributeName: "class", newValue: "not-animated" },
+ ]);
+
+ changes = await onMutations;
+
+ ok(true, "The mutations event was emitted");
+ is(changes.length, 2, "There are 2 changes in the mutation event");
+ ok(
+ changes.every(({ type }) => type === "removed"),
+ "Both are removals"
+ );
+ ok(
+ changes[0].player === p1 || changes[0].player === p2,
+ "The first removed player was one of the previously added players"
+ );
+ ok(
+ changes[1].player === p1 || changes[1].player === p2,
+ "The second removed player was one of the previously added players"
+ );
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_animation_getMultipleStates.js b/devtools/server/tests/browser/browser_animation_getMultipleStates.js
new file mode 100644
index 0000000000..77e6a7722b
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_getMultipleStates.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the duration, iterationCount and delay are retrieved correctly for
+// multiple animations.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ await playerHasAnInitialState(walker, animations);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function playerHasAnInitialState(walker, animations) {
+ let state = await getAnimationStateForNode(
+ walker,
+ animations,
+ ".delayed-multiple-animations",
+ 0
+ );
+
+ is(state.duration, 500, "The duration of the first animation is correct");
+ is(
+ state.iterationCount,
+ 10,
+ "The iterationCount of the first animation is correct"
+ );
+ is(state.delay, 1000, "The delay of the first animation is correct");
+
+ state = await getAnimationStateForNode(
+ walker,
+ animations,
+ ".delayed-multiple-animations",
+ 1
+ );
+
+ is(state.duration, 1000, "The duration of the second animation is correct");
+ is(
+ state.iterationCount,
+ 30,
+ "The iterationCount of the second animation is correct"
+ );
+ is(state.delay, 750, "The delay of the second animation is correct");
+}
+
+async function getAnimationStateForNode(
+ walker,
+ animations,
+ selector,
+ playerIndex
+) {
+ const node = await walker.querySelector(walker.rootNode, selector);
+ const players = await animations.getAnimationPlayersForNode(node);
+ const player = players[playerIndex];
+ const state = await player.getCurrentState();
+ return state;
+}
diff --git a/devtools/server/tests/browser/browser_animation_getPlayers.js b/devtools/server/tests/browser/browser_animation_getPlayers.js
new file mode 100644
index 0000000000..de78bab02f
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_getPlayers.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check the output of getAnimationPlayersForNode
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ await theRightNumberOfPlayersIsReturned(walker, animations);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function theRightNumberOfPlayersIsReturned(walker, animations) {
+ let node = await walker.querySelector(walker.rootNode, ".not-animated");
+ let players = await animations.getAnimationPlayersForNode(node);
+ is(players.length, 0, "0 players were returned for the unanimated node");
+
+ node = await walker.querySelector(walker.rootNode, ".simple-animation");
+ players = await animations.getAnimationPlayersForNode(node);
+ is(players.length, 1, "One animation player was returned");
+
+ node = await walker.querySelector(walker.rootNode, ".multiple-animations");
+ players = await animations.getAnimationPlayersForNode(node);
+ is(players.length, 2, "Two animation players were returned");
+
+ node = await walker.querySelector(walker.rootNode, ".transition");
+ players = await animations.getAnimationPlayersForNode(node);
+ is(
+ players.length,
+ 1,
+ "One animation player was returned for the transitioned node"
+ );
+}
diff --git a/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js b/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js
new file mode 100644
index 0000000000..038d7b4911
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+// Check that the right duration/iterationCount/delay are retrieved even when
+// the node has multiple animations and one of them already ended before getting
+// the player objects.
+// See devtools/server/actors/animation.js |getPlayerIndex| for more
+// information.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ info("Retrieve a non animated node");
+ const node = await walker.querySelector(walker.rootNode, ".not-animated");
+
+ info("Apply the multiple-animations-2 class to start the animations");
+ await node.modifyAttributes([
+ { attributeName: "class", newValue: "multiple-animations-2" },
+ ]);
+
+ info(
+ "Get the list of players, by the time this executes, the first, " +
+ "short, animation should have ended."
+ );
+ let players = await animations.getAnimationPlayersForNode(node);
+ if (players.length === 3) {
+ info("The short animation hasn't ended yet, wait for a bit.");
+ // The animation lasts for 500ms, so 1000ms should do it.
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ info("And get the list again");
+ players = await animations.getAnimationPlayersForNode(node);
+ }
+
+ is(players.length, 2, "2 animations remain on the node");
+
+ is(
+ players[0].state.duration,
+ 100000,
+ "The duration of the first animation is correct"
+ );
+ is(
+ players[0].state.delay,
+ 2000,
+ "The delay of the first animation is correct"
+ );
+ is(
+ players[0].state.iterationCount,
+ null,
+ "The iterationCount of the first animation is correct"
+ );
+
+ is(
+ players[1].state.duration,
+ 300000,
+ "The duration of the second animation is correct"
+ );
+ is(
+ players[1].state.delay,
+ 1000,
+ "The delay of the second animation is correct"
+ );
+ is(
+ players[1].state.iterationCount,
+ 100,
+ "The iterationCount of the second animation is correct"
+ );
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js b/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js
new file mode 100644
index 0000000000..0a8a420c18
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the AnimationsActor can retrieve all animations inside a node's
+// subtree (but not going into iframes).
+
+const URL = MAIN_DOMAIN + "animation.html";
+
+// Import inspector's shared head.
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js",
+ this
+);
+
+add_task(async function () {
+ info("Creating a test document with 2 iframes containing animated nodes");
+
+ const { inspector, target, walker, animations } =
+ await initAnimationsFrontForUrl(
+ "data:text/html;charset=utf-8," +
+ "<iframe id='iframe' src='" +
+ URL +
+ "'></iframe>"
+ );
+
+ info("Try retrieving all animations from the root doc's <body> node");
+ const rootBody = await walker.querySelector(walker.rootNode, "body");
+ let players = await animations.getAnimationPlayersForNode(rootBody);
+ is(players.length, 0, "The node has no animation players");
+
+ info("Retrieve all animations from the iframe's <body> node");
+ const frameBody = await getNodeFrontInFrames(["#iframe", "body"], inspector);
+ const animationsForFrame = await frameBody.targetFront.getFront("animations");
+ players = await animationsForFrame.getAnimationPlayersForNode(frameBody);
+
+ // Testing for a hard-coded number of animations here would intermittently
+ // fail depending on how fast or slow the test is (indeed, the test page
+ // contains short transitions, and delayed animations). So just make sure we
+ // at least have the infinitely running animations.
+ Assert.greaterOrEqual(
+ players.length,
+ 4,
+ "All subtree animations were retrieved"
+ );
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_animation_keepFinished.js b/devtools/server/tests/browser/browser_animation_keepFinished.js
new file mode 100644
index 0000000000..0adb98ad69
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_keepFinished.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+// Test that the AnimationsActor doesn't report finished animations as removed.
+// Indeed, animations that only have the "finished" playState can be modified
+// still, so we want the AnimationsActor to preserve the corresponding
+// AnimationPlayerActor.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ info("Retrieve a non-animated node");
+ const node = await walker.querySelector(walker.rootNode, ".not-animated");
+
+ info("Retrieve the animation player for the node");
+ let players = await animations.getAnimationPlayersForNode(node);
+ is(players.length, 0, "The node has no animation players");
+
+ info("Listen for new animations");
+ let reportedMutations = [];
+ function onMutations(mutations) {
+ reportedMutations = [...reportedMutations, ...mutations];
+ }
+ animations.on("mutations", onMutations);
+
+ info("Add a short animation on the node");
+ await node.modifyAttributes([
+ { attributeName: "class", newValue: "short-animation" },
+ ]);
+
+ info("Wait for longer than the animation's duration");
+ await wait(2000);
+
+ players = await animations.getAnimationPlayersForNode(node);
+ is(players.length, 0, "The added animation is surely finished");
+
+ is(reportedMutations.length, 1, "Only one mutation was reported");
+ is(reportedMutations[0].type, "added", "The mutation was an addition");
+
+ animations.off("mutations", onMutations);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function wait(ms) {
+ return new Promise(resolve => {
+ setTimeout(resolve, ms);
+ });
+}
diff --git a/devtools/server/tests/browser/browser_animation_playPauseIframe.js b/devtools/server/tests/browser/browser_animation_playPauseIframe.js
new file mode 100644
index 0000000000..e10fceb0bc
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_playPauseIframe.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the AnimationsActor can pause/play all animations even those
+// within iframes.
+
+const URL = MAIN_DOMAIN + "animation.html";
+
+// Import inspector's shared head.
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js",
+ this
+);
+
+add_task(async function () {
+ info("Creating a test document with 2 iframes containing animated nodes");
+
+ const { inspector, target } = await initAnimationsFrontForUrl(
+ "data:text/html;charset=utf-8," +
+ "<iframe id='i1' src='" +
+ URL +
+ "'></iframe>" +
+ "<iframe id='i2' src='" +
+ URL +
+ "'></iframe>"
+ );
+
+ info("Getting the 2 iframe container nodes and animated nodes in them");
+ const nodeInFrame1 = await getNodeFrontInFrames(
+ ["#i1", ".simple-animation"],
+ inspector
+ );
+ const nodeInFrame2 = await getNodeFrontInFrames(
+ ["#i2", ".simple-animation"],
+ inspector
+ );
+
+ info("Pause all animations in the test document");
+ await toggleAndCheckStates(nodeInFrame1, "paused");
+ await toggleAndCheckStates(nodeInFrame2, "paused");
+
+ info("Play all animations in the test document");
+ await toggleAndCheckStates(nodeInFrame1, "running");
+ await toggleAndCheckStates(nodeInFrame2, "running");
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function toggleAndCheckStates(nodeFront, playState) {
+ const animations = await nodeFront.targetFront.getFront("animations");
+ const [player] = await animations.getAnimationPlayersForNode(nodeFront);
+
+ if (playState === "paused") {
+ await animations.pauseSome([player]);
+ } else {
+ await animations.playSome([player]);
+ }
+
+ info("Getting the AnimationPlayerFront for the test node");
+ await player.ready;
+ const state = await player.getCurrentState();
+ is(
+ state.playState,
+ playState,
+ "The playState of the test node is " + playState
+ );
+}
diff --git a/devtools/server/tests/browser/browser_animation_playPauseSeveral.js b/devtools/server/tests/browser/browser_animation_playPauseSeveral.js
new file mode 100644
index 0000000000..d478a801d0
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_playPauseSeveral.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the AnimationsActor can pause/play a given list of animations at once.
+
+// List of selectors that match "all" animated nodes in the test page.
+// This list misses a bunch of animated nodes on purpose. Only the ones that
+// have infinite animations are listed. This is done to avoid intermittents
+// caused when finite animations are already done playing by the time the test
+// runs.
+const ALL_ANIMATED_NODES = [
+ ".simple-animation",
+ ".multiple-animations",
+ ".delayed-animation",
+];
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ info("Pause all animations in the test document");
+ await toggleAndCheckStates(walker, animations, ALL_ANIMATED_NODES, "paused");
+
+ info("Play all animations in the test document");
+ await toggleAndCheckStates(walker, animations, ALL_ANIMATED_NODES, "running");
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function toggleAndCheckStates(walker, animations, selectors, playState) {
+ info(
+ "Checking the playState of all the nodes that have infinite running " +
+ "animations"
+ );
+
+ for (const selector of selectors) {
+ const players = await getPlayersFor(walker, animations, selector);
+
+ if (playState === "paused") {
+ await animations.pauseSome(players);
+ } else {
+ await animations.playSome(players);
+ }
+
+ info("Getting the AnimationPlayerFront for node " + selector);
+ const player = players[0];
+ await checkPlayState(player, selector, playState);
+ }
+}
+
+async function getPlayersFor(walker, animations, selector) {
+ const node = await walker.querySelector(walker.rootNode, selector);
+ return animations.getAnimationPlayersForNode(node);
+}
+
+async function checkPlayState(player, selector, expectedState) {
+ const state = await player.getCurrentState();
+ is(
+ state.playState,
+ expectedState,
+ "The playState of node " + selector + " is " + expectedState
+ );
+}
diff --git a/devtools/server/tests/browser/browser_animation_playerState.js b/devtools/server/tests/browser/browser_animation_playerState.js
new file mode 100644
index 0000000000..e010b576b5
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_playerState.js
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check the animation player's initial state
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ await playerHasAnInitialState(walker, animations);
+ await playerStateIsCorrect(walker, animations);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function playerHasAnInitialState(walker, animations) {
+ const node = await walker.querySelector(walker.rootNode, ".simple-animation");
+ const [player] = await animations.getAnimationPlayersForNode(node);
+
+ ok(player.initialState, "The player front has an initial state");
+ ok("startTime" in player.initialState, "Player's state has startTime");
+ ok("currentTime" in player.initialState, "Player's state has currentTime");
+ ok("playState" in player.initialState, "Player's state has playState");
+ ok("playbackRate" in player.initialState, "Player's state has playbackRate");
+ ok("name" in player.initialState, "Player's state has name");
+ ok("duration" in player.initialState, "Player's state has duration");
+ ok("delay" in player.initialState, "Player's state has delay");
+ ok(
+ "iterationCount" in player.initialState,
+ "Player's state has iterationCount"
+ );
+ ok("fill" in player.initialState, "Player's state has fill");
+ ok("easing" in player.initialState, "Player's state has easing");
+ ok("direction" in player.initialState, "Player's state has direction");
+ ok(
+ "isRunningOnCompositor" in player.initialState,
+ "Player's state has isRunningOnCompositor"
+ );
+ ok("type" in player.initialState, "Player's state has type");
+ ok(
+ "documentCurrentTime" in player.initialState,
+ "Player's state has documentCurrentTime"
+ );
+ ok("properties" in player.initialState, "Player's state has properties");
+}
+
+async function playerStateIsCorrect(walker, animations) {
+ info("Checking the state of the simple animation");
+
+ let player = await getAnimationPlayerForNode(
+ walker,
+ animations,
+ ".simple-animation",
+ 0
+ );
+ let state = await player.getCurrentState();
+ is(state.name, "move", "Name is correct");
+ is(state.duration, 200000, "Duration is correct");
+ // null = infinite count
+ is(state.iterationCount, null, "Iteration count is correct");
+ is(state.fill, "none", "Fill is correct");
+ is(state.easing, "linear", "Easing is correct");
+ is(state.direction, "normal", "Direction is correct");
+ is(state.playState, "running", "PlayState is correct");
+ is(state.playbackRate, 1, "PlaybackRate is correct");
+ is(state.type, "cssanimation", "Type is correct");
+
+ info("Checking the state of the transition");
+
+ player = await getAnimationPlayerForNode(
+ walker,
+ animations,
+ ".transition",
+ 0
+ );
+ state = await player.getCurrentState();
+ is(state.name, "width", "Transition name matches transition property");
+ is(state.duration, 500000, "Transition duration is correct");
+ // transitions run only once
+ is(state.iterationCount, 1, "Transition iteration count is correct");
+ is(state.fill, "backwards", "Transition fill is correct");
+ is(state.easing, "ease-out", "Transition easing is correct");
+ is(state.direction, "normal", "Transition direction is correct");
+ is(state.playState, "running", "Transition playState is correct");
+ is(state.playbackRate, 1, "Transition playbackRate is correct");
+ is(state.type, "csstransition", "Transition type is correct");
+ // check easing in properties
+ let properties = state.properties;
+ is(properties.length, 1, "Length of animated properties is correct");
+ let keyframes = properties[0].values;
+ is(keyframes.length, 2, "Transition length of keyframe is correct");
+ is(keyframes[0].easing, "linear", "Transition keyframes's easing is correct");
+
+ info("Checking the state of one of multiple animations on a node");
+
+ // Checking the 2nd player
+ player = await getAnimationPlayerForNode(
+ walker,
+ animations,
+ ".multiple-animations",
+ 1
+ );
+ state = await player.getCurrentState();
+ is(state.name, "glow", "The 2nd animation's name is correct");
+ is(state.duration, 100000, "The 2nd animation's duration is correct");
+ is(state.iterationCount, 5, "The 2nd animation's iteration count is correct");
+ is(state.fill, "both", "The 2nd animation's fill is correct");
+ is(state.easing, "linear", "The 2nd animation's easing is correct");
+ is(state.direction, "reverse", "The 2nd animation's direction is correct");
+ is(state.playState, "running", "The 2nd animation's playState is correct");
+ is(state.playbackRate, 1, "The 2nd animation's playbackRate is correct");
+ // chech easing in keyframe
+ properties = state.properties;
+ keyframes = properties[0].values;
+ is(keyframes.length, 2, "The 2nd animation's length of keyframe is correct");
+ is(
+ keyframes[0].easing,
+ "ease-out",
+ "The 2nd animation's easing of keyframes is correct"
+ );
+
+ info("Checking the state of an animation with delay");
+
+ player = await getAnimationPlayerForNode(
+ walker,
+ animations,
+ ".delayed-animation",
+ 0
+ );
+ state = await player.getCurrentState();
+ is(state.delay, 5000, "The animation delay is correct");
+
+ info("Checking the state of an transition with delay");
+
+ player = await getAnimationPlayerForNode(
+ walker,
+ animations,
+ ".delayed-transition",
+ 0
+ );
+ state = await player.getCurrentState();
+ is(state.delay, 3000, "The transition delay is correct");
+}
+
+async function getAnimationPlayerForNode(
+ walker,
+ animations,
+ nodeSelector,
+ index
+) {
+ const node = await walker.querySelector(walker.rootNode, nodeSelector);
+ const players = await animations.getAnimationPlayersForNode(node);
+ const player = players[index];
+ return player;
+}
diff --git a/devtools/server/tests/browser/browser_animation_reconstructState.js b/devtools/server/tests/browser/browser_animation_reconstructState.js
new file mode 100644
index 0000000000..d7174562d9
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_reconstructState.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that, even though the AnimationPlayerActor only sends the bits of its
+// state that change, the front reconstructs the whole state everytime.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ await playerHasCompleteStateAtAllTimes(walker, animations);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function playerHasCompleteStateAtAllTimes(walker, animations) {
+ const node = await walker.querySelector(walker.rootNode, ".simple-animation");
+ const [player] = await animations.getAnimationPlayersForNode(node);
+
+ // Get the list of state key names from the initialstate.
+ const keys = Object.keys(player.initialState);
+
+ // Get the state over and over again and check that the object returned
+ // contains all keys.
+ // Normally, only the currentTime will have changed in between 2 calls.
+ for (let i = 0; i < 10; i++) {
+ await player.refreshState();
+ keys.forEach(key => {
+ Assert.notStrictEqual(
+ typeof player.state[key],
+ "undefined",
+ "The state retrieved has key " + key
+ );
+ });
+ }
+}
diff --git a/devtools/server/tests/browser/browser_animation_refreshTransitions.js b/devtools/server/tests/browser/browser_animation_refreshTransitions.js
new file mode 100644
index 0000000000..a48ea90e3d
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_refreshTransitions.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// When a transition finishes, no "removed" event is sent because it may still
+// be used, but when it restarts again (transitions back), then a new
+// AnimationPlayerFront should be sent, and the old one should be removed.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ info("Retrieve the test node");
+ const node = await walker.querySelector(walker.rootNode, ".all-transitions");
+
+ info("Retrieve the animation players for the node");
+ const players = await animations.getAnimationPlayersForNode(node);
+ is(players.length, 0, "The node has no animation players yet");
+
+ info("Play a transition by adding the expand class, wait for mutations");
+ let onMutations = expectMutationEvents(animations, 2);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ const el = content.document.querySelector(".all-transitions");
+ el.classList.add("expand");
+ });
+ let reportedMutations = await onMutations;
+
+ is(reportedMutations.length, 2, "2 mutation events were received");
+ is(reportedMutations[0].type, "added", "The first event was 'added'");
+ is(reportedMutations[1].type, "added", "The second event was 'added'");
+
+ info("Wait for the transitions to be finished");
+ await waitForEnd(reportedMutations[0].player);
+ await waitForEnd(reportedMutations[1].player);
+
+ info("Play the transition back by removing the class, wait for mutations");
+ onMutations = expectMutationEvents(animations, 4);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ const el = content.document.querySelector(".all-transitions");
+ el.classList.remove("expand");
+ });
+ reportedMutations = await onMutations;
+
+ is(reportedMutations.length, 4, "4 new mutation events were received");
+ is(
+ reportedMutations.filter(m => m.type === "removed").length,
+ 2,
+ "2 'removed' events were sent (for the old transitions)"
+ );
+ is(
+ reportedMutations.filter(m => m.type === "added").length,
+ 2,
+ "2 'added' events were sent (for the new transitions)"
+ );
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+function expectMutationEvents(animationsFront, nbOfEvents) {
+ return new Promise(resolve => {
+ let reportedMutations = [];
+ function onMutations(mutations) {
+ reportedMutations = [...reportedMutations, ...mutations];
+ info(
+ "Received " +
+ reportedMutations.length +
+ " mutation events, " +
+ "expecting " +
+ nbOfEvents
+ );
+ if (reportedMutations.length === nbOfEvents) {
+ animationsFront.off("mutations", onMutations);
+ resolve(reportedMutations);
+ }
+ }
+
+ info("Start listening for mutation events from the AnimationsFront");
+ animationsFront.on("mutations", onMutations);
+ });
+}
+
+async function waitForEnd(animationFront) {
+ let playState;
+ while (playState !== "finished") {
+ const state = await animationFront.getCurrentState();
+ playState = state.playState;
+ info(
+ "Wait for transition " +
+ animationFront.state.name +
+ " to finish, playState=" +
+ playState
+ );
+ }
+}
diff --git a/devtools/server/tests/browser/browser_animation_setCurrentTime.js b/devtools/server/tests/browser/browser_animation_setCurrentTime.js
new file mode 100644
index 0000000000..8f7228cdd8
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_setCurrentTime.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that the AnimationsActor allows changing many players' currentTimes at once.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ await testSetCurrentTimes(walker, animations);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function testSetCurrentTimes(walker, animations) {
+ ok(animations.setCurrentTimes, "The AnimationsActor has the right method");
+
+ info("Retrieve multiple animated node and its animation players");
+
+ const nodeMulti = await walker.querySelector(
+ walker.rootNode,
+ ".multiple-animations"
+ );
+ const players = await animations.getAnimationPlayersForNode(nodeMulti);
+
+ Assert.greater(players.length, 1, "Node has more than 1 animation player");
+
+ info("Try to set multiple current times at once");
+ // Assume that all animations were created at same time.
+ const createdTime = players[1].state.createdTime;
+ await animations.setCurrentTimes(players, createdTime + 500, true);
+
+ info("Get the states of players and verify their correctness");
+ for (let i = 0; i < players.length; i++) {
+ const state = await players[i].getCurrentState();
+ is(state.playState, "paused", `Player ${i + 1} is paused`);
+ is(
+ parseInt(state.currentTime.toPrecision(4), 10),
+ 500,
+ `Player ${i + 1} has the right currentTime`
+ );
+ }
+}
diff --git a/devtools/server/tests/browser/browser_animation_setPlaybackRate.js b/devtools/server/tests/browser/browser_animation_setPlaybackRate.js
new file mode 100644
index 0000000000..b14751b114
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_setPlaybackRate.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that a player's playbackRate can be changed, and that multiple players
+// can have their rates changed at the same time.
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ info("Retrieve an animated node");
+ let node = await walker.querySelector(walker.rootNode, ".simple-animation");
+
+ info("Retrieve the animation player for the node");
+ const [player] = await animations.getAnimationPlayersForNode(node);
+
+ info("Change the rate to 10");
+ await animations.setPlaybackRates([player], 10);
+
+ info("Query the state again");
+ let state = await player.getCurrentState();
+ is(state.playbackRate, 10, "The playbackRate was updated");
+
+ info("Change the rate back to 1");
+ await animations.setPlaybackRates([player], 1);
+
+ info("Query the state again");
+ state = await player.getCurrentState();
+ is(state.playbackRate, 1, "The playbackRate was changed back");
+
+ info("Retrieve several animation players and set their rates");
+ node = await walker.querySelector(walker.rootNode, "body");
+ const players = await animations.getAnimationPlayersForNode(node);
+
+ info("Change all animations in <body> to .5 rate");
+ await animations.setPlaybackRates(players, 0.5);
+
+ info("Query their states and check they are correct");
+ for (const animPlayer of players) {
+ const animPlayerState = await animPlayer.getCurrentState();
+ is(animPlayerState.playbackRate, 0.5, "The playbackRate was updated");
+ }
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_animation_simple.js b/devtools/server/tests/browser/browser_animation_simple.js
new file mode 100644
index 0000000000..0dd8adfde9
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_simple.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Simple checks for the AnimationsActor
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ "data:text/html;charset=utf-8,<title>test</title><div></div>"
+ );
+
+ ok(animations, "The AnimationsFront was created");
+ ok(
+ animations.getAnimationPlayersForNode,
+ "The getAnimationPlayersForNode method exists"
+ );
+ ok(animations.pauseSome, "The pauseSome method exists");
+ ok(animations.playSome, "The playSome method exists");
+ ok(animations.setCurrentTimes, "The setCurrentTimes method exists");
+ ok(animations.setPlaybackRates, "The setPlaybackRates method exists");
+ ok(animations.setWalkerActor, "The setWalkerActor method exists");
+
+ let didThrow = false;
+ try {
+ await animations.getAnimationPlayersForNode(null);
+ } catch (e) {
+ didThrow = true;
+ }
+ ok(didThrow, "An exception was thrown for a missing NodeActor");
+
+ const invalidNode = await walker.querySelector(walker.rootNode, "title");
+ const players = await animations.getAnimationPlayersForNode(invalidNode);
+ ok(Array.isArray(players), "An array of players was returned");
+ is(players.length, 0, "0 players have been returned for the invalid node");
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_animation_updatedState.js b/devtools/server/tests/browser/browser_animation_updatedState.js
new file mode 100644
index 0000000000..4b1420de52
--- /dev/null
+++ b/devtools/server/tests/browser/browser_animation_updatedState.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+// Check the animation player's updated state
+
+add_task(async function () {
+ const { target, walker, animations } = await initAnimationsFrontForUrl(
+ MAIN_DOMAIN + "animation.html"
+ );
+
+ await playStateIsUpdatedDynamically(walker, animations);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
+
+async function playStateIsUpdatedDynamically(walker, animations) {
+ info("Getting the test node (which runs a very long animation)");
+ // The animation lasts for 100s, to avoid intermittents.
+ const node = await walker.querySelector(walker.rootNode, ".long-animation");
+
+ info("Getting the animation player front for this node");
+ const [player] = await animations.getAnimationPlayersForNode(node);
+
+ let state = await player.getCurrentState();
+ is(
+ state.playState,
+ "running",
+ "The playState is running while the animation is running"
+ );
+
+ info(
+ "Change the animation's currentTime to be near the end and wait for " +
+ "it to finish"
+ );
+ const onFinished = waitForAnimationPlayState(player, "finished");
+ // Set the currentTime to 98s, knowing that the animation lasts for 100s.
+ await animations.setCurrentTimes([player], 98 * 1000, false);
+ state = await onFinished;
+ is(
+ state.playState,
+ "finished",
+ "The animation has ended and the state has been updated"
+ );
+ Assert.greater(
+ state.currentTime,
+ player.initialState.currentTime,
+ "The currentTime has been updated"
+ );
+}
+
+async function waitForAnimationPlayState(player, playState) {
+ let state = {};
+ while (state.playState !== playState) {
+ state = await player.getCurrentState();
+ await wait(500);
+ }
+ return state;
+}
+
+function wait(ms) {
+ return new Promise(r => setTimeout(r, ms));
+}
diff --git a/devtools/server/tests/browser/browser_application_manifest.js b/devtools/server/tests/browser/browser_application_manifest.js
new file mode 100644
index 0000000000..c92a3c0a2f
--- /dev/null
+++ b/devtools/server/tests/browser/browser_application_manifest.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Enable web manifest processing.
+Services.prefs.setBoolPref("dom.manifest.enabled", true);
+
+add_task(async function () {
+ info("Testing fetching a valid manifest");
+ const response = await fetchManifest("application-manifest-basic.html");
+
+ ok(
+ response.manifest && response.manifest.name == "FooApp",
+ "Returns an object populated with the manifest data"
+ );
+});
+
+add_task(async function () {
+ info("Testing fetching an existing manifest with invalid values");
+ const response = await fetchManifest("application-manifest-warnings.html");
+
+ ok(
+ response.manifest && response.manifest.moz_validation,
+ "Returns an object populated with the manifest data"
+ );
+
+ const warnings = response.manifest.moz_validation;
+ ok(
+ warnings.length === 1 &&
+ warnings[0].warn &&
+ warnings[0].warn.includes("name member to be a string"),
+ "The returned object contains the expected warning info"
+ );
+});
+
+add_task(async function () {
+ info("Testing fetching a manifest in a page that does not have one");
+ const response = await fetchManifest("application-manifest-no-manifest.html");
+
+ is(response.manifest, null, "Returns an object with a `null` manifest");
+ ok(!response.errorMessage, "Does not return an error message");
+});
+
+add_task(async function () {
+ info("Testing an error happening fetching a manifest");
+ // the page that we are testing contains an invalid URL for the manifest
+ const response = await fetchManifest(
+ "application-manifest-404-manifest.html"
+ );
+
+ is(response.manifest, null, "Returns an object with a `null` manifest");
+ ok(
+ response.errorMessage &&
+ response.errorMessage.toLowerCase().includes("404 - not found"),
+ "Returns the expected error message"
+ );
+});
+
+add_task(async function () {
+ info("Testing a validation error when fetching a manifest with invalid JSON");
+ const response = await fetchManifest(
+ "application-manifest-invalid-json.html"
+ );
+ ok(
+ response.manifest && response.manifest.moz_validation,
+ "Returns an object with validation data"
+ );
+ const validation = response.manifest.moz_validation;
+ ok(
+ validation.find(x => x.error && x.type === "json"),
+ "Has the expected error in the validation field"
+ );
+});
+
+async function fetchManifest(filename) {
+ const url = MAIN_DOMAIN + filename;
+ const target = await addTabTarget(url);
+
+ info("Initializing manifest front for tab");
+ const manifestFront = await target.getFront("manifest");
+
+ info("Fetching manifest");
+ const response = await manifestFront.fetchCanonicalManifest();
+
+ return response;
+}
diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_01.js b/devtools/server/tests/browser/browser_canvasframe_helper_01.js
new file mode 100644
index 0000000000..14c947db7e
--- /dev/null
+++ b/devtools/server/tests/browser/browser_canvasframe_helper_01.js
@@ -0,0 +1,170 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Simple CanvasFrameAnonymousContentHelper tests.
+
+const TEST_URL =
+ "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URL);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ CanvasFrameAnonymousContentHelper,
+ } = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+ const doc = content.document;
+
+ const nodeBuilder = () => {
+ const root = doc.createElement("div");
+ const child = doc.createElement("div");
+ child.style = "width:200px;height:200px;background:red;";
+ child.id = "child-element";
+ child.className = "child-element";
+ child.textContent = "test element";
+ root.appendChild(child);
+ return root;
+ };
+
+ info("Building the helper");
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(doc.defaultView);
+ const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder);
+ await helper.initialize();
+
+ ok(
+ content.AnonymousContent.isInstance(helper.content),
+ "The helper owns the AnonymousContent object"
+ );
+ ok(
+ helper.getTextContentForElement,
+ "The helper has the getTextContentForElement method"
+ );
+ ok(
+ helper.setTextContentForElement,
+ "The helper has the setTextContentForElement method"
+ );
+ ok(
+ helper.setAttributeForElement,
+ "The helper has the setAttributeForElement method"
+ );
+ ok(
+ helper.getAttributeForElement,
+ "The helper has the getAttributeForElement method"
+ );
+ ok(
+ helper.removeAttributeForElement,
+ "The helper has the removeAttributeForElement method"
+ );
+ ok(
+ helper.addEventListenerForElement,
+ "The helper has the addEventListenerForElement method"
+ );
+ ok(
+ helper.removeEventListenerForElement,
+ "The helper has the removeEventListenerForElement method"
+ );
+ ok(helper.getElement, "The helper has the getElement method");
+ ok(helper.scaleRootElement, "The helper has the scaleRootElement method");
+
+ is(
+ helper.getTextContentForElement("child-element"),
+ "test element",
+ "The text content was retrieve correctly"
+ );
+ is(
+ helper.getAttributeForElement("child-element", "id"),
+ "child-element",
+ "The ID attribute was retrieve correctly"
+ );
+ is(
+ helper.getAttributeForElement("child-element", "class"),
+ "child-element",
+ "The class attribute was retrieve correctly"
+ );
+
+ const el = helper.getElement("child-element");
+ ok(el, "The DOMNode-like element was created");
+
+ is(
+ el.getTextContent(),
+ "test element",
+ "The text content was retrieve correctly"
+ );
+ is(
+ el.getAttribute("id"),
+ "child-element",
+ "The ID attribute was retrieve correctly"
+ );
+ is(
+ el.getAttribute("class"),
+ "child-element",
+ "The class attribute was retrieve correctly"
+ );
+
+ info("Test the toggle API");
+ el.classList.toggle("test"); // This will set the class
+ is(
+ el.getAttribute("class"),
+ "child-element test",
+ "After toggling the class 'test', the class attribute contained the 'test' class"
+ );
+ el.classList.toggle("test"); // This will remove the class
+ is(
+ el.getAttribute("class"),
+ "child-element",
+ "After toggling the class 'test' again, the class attribute removed the 'test' class"
+ );
+ el.classList.toggle("test", true); // This will set the class
+ is(
+ el.getAttribute("class"),
+ "child-element test",
+ "After toggling the class 'test' again and keeping force=true, the class attribute added the 'test' class"
+ );
+ el.classList.toggle("test", true); // This will keep the class set
+ is(
+ el.getAttribute("class"),
+ "child-element test",
+ "After toggling the class 'test' again and keeping force=true,the class attribute contained the 'test' class"
+ );
+ el.classList.toggle("test", false); // This will remove the class
+ is(
+ el.getAttribute("class"),
+ "child-element",
+ "After toggling the class 'test' again and keeping force=false, the class attribute removed the 'test' class"
+ );
+ el.classList.toggle("test", false); // This will keep the class removed
+ is(
+ el.getAttribute("class"),
+ "child-element",
+ "After toggling the class 'test' again and keeping force=false, the class attribute removed the 'test' class"
+ );
+
+ info("Destroying the helper");
+ helper.destroy();
+ env.destroy();
+
+ ok(
+ !helper.getTextContentForElement("child-element"),
+ "No text content was retrieved after the helper was destroyed"
+ );
+ ok(
+ !helper.getAttributeForElement("child-element", "id"),
+ "No ID attribute was retrieved after the helper was destroyed"
+ );
+ ok(
+ !helper.getAttributeForElement("child-element", "class"),
+ "No class attribute was retrieved after the helper was destroyed"
+ );
+ });
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_02.js b/devtools/server/tests/browser/browser_canvasframe_helper_02.js
new file mode 100644
index 0000000000..bd54a03933
--- /dev/null
+++ b/devtools/server/tests/browser/browser_canvasframe_helper_02.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the CanvasFrameAnonymousContentHelper does not insert content in
+// XUL windows.
+
+add_task(async function () {
+ const tab = await addTab(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/test-window.xhtml"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ CanvasFrameAnonymousContentHelper,
+ } = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+ const doc = content.document;
+
+ const nodeBuilder = () => {
+ const root = doc.createElement("div");
+ const child = doc.createElement("div");
+ child.style = "width:200px;height:200px;background:red;";
+ child.id = "child-element";
+ child.className = "child-element";
+ child.textContent = "test element";
+ root.appendChild(child);
+ return root;
+ };
+
+ info("Building the helper");
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(doc.defaultView);
+ const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder);
+
+ ok(!helper.content, "The AnonymousContent was not inserted in the window");
+ ok(
+ !helper.getTextContentForElement("child-element"),
+ "No text content is returned"
+ );
+
+ env.destroy();
+ helper.destroy();
+ });
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_03.js b/devtools/server/tests/browser/browser_canvasframe_helper_03.js
new file mode 100644
index 0000000000..52aa4b5a6f
--- /dev/null
+++ b/devtools/server/tests/browser/browser_canvasframe_helper_03.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the CanvasFrameAnonymousContentHelper event handling mechanism.
+
+const TEST_URL =
+ "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URL);
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ CanvasFrameAnonymousContentHelper,
+ } = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+ const doc = content.document;
+
+ const nodeBuilder = () => {
+ const root = doc.createElement("div");
+ const child = doc.createElement("div");
+ child.style =
+ "pointer-events:auto;width:200px;height:200px;background:red;";
+ child.id = "child-element";
+ child.className = "child-element";
+ root.appendChild(child);
+ return root;
+ };
+
+ info("Building the helper");
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(doc.defaultView);
+ const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder);
+ await helper.initialize();
+
+ const el = helper.getElement("child-element");
+
+ info("Adding an event listener on the inserted element");
+ let mouseDownHandled = 0;
+ function onMouseDown(e, id) {
+ is(
+ id,
+ "child-element",
+ "The mousedown event was triggered on the element"
+ );
+ ok(!e.originalTarget, "The originalTarget property isn't available");
+ mouseDownHandled++;
+ }
+ el.addEventListener("mousedown", onMouseDown);
+
+ function once(target, event) {
+ return new Promise(done => {
+ target.addEventListener(event, done, { once: true });
+ });
+ }
+
+ info("Synthesizing an event on the inserted element");
+ let onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+
+ is(
+ mouseDownHandled,
+ 1,
+ "The mousedown event was handled once on the element"
+ );
+
+ info("Synthesizing an event somewhere else");
+ onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(400, 400, doc.defaultView);
+ await onDocMouseDown;
+
+ is(
+ mouseDownHandled,
+ 1,
+ "The mousedown event was not handled on the element"
+ );
+
+ info("Removing the event listener");
+ el.removeEventListener("mousedown", onMouseDown);
+
+ info("Synthesizing another event after the listener has been removed");
+ // Using a document event listener to know when the event has been synthesized.
+ onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+
+ is(
+ mouseDownHandled,
+ 1,
+ "The mousedown event hasn't been handled after the listener was removed"
+ );
+
+ info("Adding again the event listener");
+ el.addEventListener("mousedown", onMouseDown);
+
+ info("Destroying the helper");
+ env.destroy();
+ helper.destroy();
+
+ info("Synthesizing another event after the helper has been destroyed");
+ // Using a document event listener to know when the event has been synthesized.
+ onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+
+ is(
+ mouseDownHandled,
+ 1,
+ "The mousedown event hasn't been handled after the helper was destroyed"
+ );
+
+ function synthesizeMouseDown(x, y, win) {
+ // We need to make sure the inserted anonymous content can be targeted by the
+ // event right after having been inserted, and so we need to force a sync
+ // reflow.
+ win.document.documentElement.offsetWidth;
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win);
+ }
+ });
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_04.js b/devtools/server/tests/browser/browser_canvasframe_helper_04.js
new file mode 100644
index 0000000000..85368ff2b5
--- /dev/null
+++ b/devtools/server/tests/browser/browser_canvasframe_helper_04.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the CanvasFrameAnonymousContentHelper re-inserts the content when the
+// page reloads.
+
+const TEST_URL_1 =
+ "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test 1";
+const TEST_URL_2 =
+ "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test 2";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URL_1);
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [TEST_URL_2],
+ async function (url2) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ CanvasFrameAnonymousContentHelper,
+ } = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+ let doc = content.document;
+
+ const nodeBuilder = () => {
+ const root = doc.createElement("div");
+ const child = doc.createElement("div");
+ child.style =
+ "pointer-events:auto;width:200px;height:200px;background:red;";
+ child.id = "child-element";
+ child.className = "child-element";
+ child.textContent = "test content";
+ root.appendChild(child);
+ return root;
+ };
+
+ info("Building the helper");
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(doc.defaultView);
+ const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder);
+ await helper.initialize();
+
+ info("Get an element from the helper");
+ const el = helper.getElement("child-element");
+
+ info("Try to access the element");
+ is(
+ el.getAttribute("class"),
+ "child-element",
+ "The attribute is correct before navigation"
+ );
+ is(
+ el.getTextContent(),
+ "test content",
+ "The text content is correct before navigation"
+ );
+
+ info("Add an event listener on the element");
+ let mouseDownHandled = 0;
+ const onMouseDown = (e, id) => {
+ is(
+ id,
+ "child-element",
+ "The mousedown event was triggered on the element"
+ );
+ mouseDownHandled++;
+ };
+ el.addEventListener("mousedown", onMouseDown);
+
+ const once = function once(target, event) {
+ return new Promise(done => {
+ target.addEventListener(event, done, { once: true });
+ });
+ };
+
+ const synthesizeMouseDown = function synthesizeMouseDown(x, y, win) {
+ // We need to make sure the inserted anonymous content can be targeted by the
+ // event right after having been inserted, and so we need to force a sync
+ // reflow.
+ win.document.documentElement.offsetWidth;
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win);
+ };
+
+ info("Synthesizing an event on the element");
+ let onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+ is(
+ mouseDownHandled,
+ 1,
+ "The mousedown event was handled once before navigation"
+ );
+
+ info("Navigating to a new page");
+ const loaded = once(this, "load");
+ content.location = url2;
+ await loaded;
+
+ // Wait for the next event tick to make sure the remaining part of the
+ // test is not executed in the microtask checkpoint for load event
+ // itself. Otherwise the synthesizeMouseDown doesn't work.
+ await new Promise(r => content.setTimeout(r, 0));
+
+ // Update to the new document we just loaded
+ doc = content.document;
+
+ info("Try to access the element again");
+ is(
+ el.getAttribute("class"),
+ "child-element",
+ "The attribute is correct after navigation"
+ );
+ is(
+ el.getTextContent(),
+ "test content",
+ "The text content is correct after navigation"
+ );
+
+ info("Synthesizing an event on the element again");
+ onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+ is(
+ mouseDownHandled,
+ 1,
+ "The mousedown event was not handled after navigation"
+ );
+
+ info("Destroying the helper");
+ env.destroy();
+ helper.destroy();
+ }
+ );
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_05.js b/devtools/server/tests/browser/browser_canvasframe_helper_05.js
new file mode 100644
index 0000000000..b542b14221
--- /dev/null
+++ b/devtools/server/tests/browser/browser_canvasframe_helper_05.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test some edge cases of the CanvasFrameAnonymousContentHelper event handling
+// mechanism.
+
+const TEST_URL =
+ "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URL);
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ CanvasFrameAnonymousContentHelper,
+ } = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+ const doc = content.document;
+
+ const nodeBuilder = () => {
+ const root = doc.createElement("div");
+
+ const parent = doc.createElement("div");
+ parent.style =
+ "pointer-events:auto;width:300px;height:300px;background:yellow;";
+ parent.id = "parent-element";
+ root.appendChild(parent);
+
+ const child = doc.createElement("div");
+ child.style =
+ "pointer-events:auto;width:200px;height:200px;background:red;";
+ child.id = "child-element";
+ parent.appendChild(child);
+
+ return root;
+ };
+
+ info("Building the helper");
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(doc.defaultView);
+ const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder);
+ await helper.initialize();
+
+ info("Getting the parent and child elements");
+ const parentEl = helper.getElement("parent-element");
+ const childEl = helper.getElement("child-element");
+
+ info("Adding an event listener on both elements");
+ let mouseDownHandled = [];
+ function onMouseDown(e, id) {
+ mouseDownHandled.push(id);
+ }
+ parentEl.addEventListener("mousedown", onMouseDown);
+ childEl.addEventListener("mousedown", onMouseDown);
+
+ function once(target, event) {
+ return new Promise(done => {
+ target.addEventListener(event, done, { once: true });
+ });
+ }
+
+ info("Synthesizing an event on the child element");
+ let onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+
+ is(mouseDownHandled.length, 2, "The mousedown event was handled twice");
+ is(
+ mouseDownHandled[0],
+ "child-element",
+ "The mousedown event was handled on the child element"
+ );
+ is(
+ mouseDownHandled[1],
+ "parent-element",
+ "The mousedown event was handled on the parent element"
+ );
+
+ info("Synthesizing an event on the parent, outside of the child element");
+ mouseDownHandled = [];
+ onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(250, 250, doc.defaultView);
+ await onDocMouseDown;
+
+ is(mouseDownHandled.length, 1, "The mousedown event was handled only once");
+ is(
+ mouseDownHandled[0],
+ "parent-element",
+ "The mousedown event was handled on the parent element"
+ );
+
+ info("Removing the event listener");
+ parentEl.removeEventListener("mousedown", onMouseDown);
+ childEl.removeEventListener("mousedown", onMouseDown);
+
+ info("Adding an event listener on the parent element only");
+ mouseDownHandled = [];
+ parentEl.addEventListener("mousedown", onMouseDown);
+
+ info("Synthesizing an event on the child element");
+ onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+
+ is(mouseDownHandled.length, 1, "The mousedown event was handled once");
+ is(
+ mouseDownHandled[0],
+ "parent-element",
+ "The mousedown event did bubble to the parent element"
+ );
+
+ info("Removing the parent listener");
+ parentEl.removeEventListener("mousedown", onMouseDown);
+
+ env.destroy();
+ helper.destroy();
+
+ function synthesizeMouseDown(x, y, win) {
+ // We need to make sure the inserted anonymous content can be targeted by the
+ // event right after having been inserted, and so we need to force a sync
+ // reflow.
+ win.document.documentElement.offsetWidth;
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win);
+ }
+ });
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_06.js b/devtools/server/tests/browser/browser_canvasframe_helper_06.js
new file mode 100644
index 0000000000..e0222b33b1
--- /dev/null
+++ b/devtools/server/tests/browser/browser_canvasframe_helper_06.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test support for event propagation stop in the
+// CanvasFrameAnonymousContentHelper event handling mechanism.
+
+const TEST_URL =
+ "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test";
+
+add_task(async function () {
+ const tab = await addTab(TEST_URL);
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ HighlighterEnvironment,
+ } = require("resource://devtools/server/actors/highlighters.js");
+ const {
+ CanvasFrameAnonymousContentHelper,
+ } = require("resource://devtools/server/actors/highlighters/utils/markup.js");
+ const doc = content.document;
+
+ const nodeBuilder = () => {
+ const root = doc.createElement("div");
+
+ const parent = doc.createElement("div");
+ parent.style =
+ "pointer-events:auto;width:300px;height:300px;background:yellow;";
+ parent.id = "parent-element";
+ root.appendChild(parent);
+
+ const child = doc.createElement("div");
+ child.style =
+ "pointer-events:auto;width:200px;height:200px;background:red;";
+ child.id = "child-element";
+ parent.appendChild(child);
+
+ return root;
+ };
+
+ info("Building the helper");
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(doc.defaultView);
+ const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder);
+ await helper.initialize();
+
+ info("Getting the parent and child elements");
+ const parentEl = helper.getElement("parent-element");
+ const childEl = helper.getElement("child-element");
+
+ info("Adding an event listener on both elements");
+ let mouseDownHandled = [];
+
+ function onParentMouseDown(e, id) {
+ mouseDownHandled.push(id);
+ }
+ parentEl.addEventListener("mousedown", onParentMouseDown);
+
+ function onChildMouseDown(e, id) {
+ mouseDownHandled.push(id);
+ e.stopPropagation();
+ }
+ childEl.addEventListener("mousedown", onChildMouseDown);
+
+ function once(target, event) {
+ return new Promise(done => {
+ target.addEventListener(event, done, { once: true });
+ });
+ }
+
+ info("Synthesizing an event on the child element");
+ let onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(100, 100, doc.defaultView);
+ await onDocMouseDown;
+
+ is(mouseDownHandled.length, 1, "The mousedown event was handled only once");
+ is(
+ mouseDownHandled[0],
+ "child-element",
+ "The mousedown event was handled on the child element"
+ );
+
+ info("Synthesizing an event on the parent, outside of the child element");
+ mouseDownHandled = [];
+ onDocMouseDown = once(doc, "mousedown");
+ synthesizeMouseDown(250, 250, doc.defaultView);
+ await onDocMouseDown;
+
+ is(mouseDownHandled.length, 1, "The mousedown event was handled only once");
+ is(
+ mouseDownHandled[0],
+ "parent-element",
+ "The mousedown event was handled on the parent element"
+ );
+
+ info("Removing the event listener");
+ parentEl.removeEventListener("mousedown", onParentMouseDown);
+ childEl.removeEventListener("mousedown", onChildMouseDown);
+
+ env.destroy();
+ helper.destroy();
+
+ function synthesizeMouseDown(x, y, win) {
+ // We need to make sure the inserted anonymous content can be targeted by the
+ // event right after having been inserted, and so we need to force a sync
+ // reflow.
+ win.document.documentElement.offsetWidth;
+ EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win);
+ }
+ });
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_compatibility_cssIssues.js b/devtools/server/tests/browser/browser_compatibility_cssIssues.js
new file mode 100644
index 0000000000..4cd244688c
--- /dev/null
+++ b/devtools/server/tests/browser/browser_compatibility_cssIssues.js
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check the output of getNodeCssIssues
+
+const {
+ COMPATIBILITY_ISSUE_TYPE,
+} = require("resource://devtools/shared/constants.js");
+const URL = MAIN_DOMAIN + "doc_compatibility.html";
+
+const CHROME_81 = {
+ id: "chrome",
+ version: "81",
+};
+
+const CHROME_ANDROID = {
+ id: "chrome_android",
+ version: "81",
+};
+
+const EDGE_81 = {
+ id: "edge",
+ version: "81",
+};
+
+const FIREFOX_1 = {
+ id: "firefox",
+ version: "1",
+};
+
+const FIREFOX_60 = {
+ id: "firefox",
+ version: "60",
+};
+
+const FIREFOX_69 = {
+ id: "firefox",
+ version: "69",
+};
+
+const FIREFOX_MOBILE = {
+ id: "firefox_android",
+ version: "68",
+};
+
+const SAFARI_13 = {
+ id: "safari",
+ version: "13",
+};
+
+const SAFARI_MOBILE = {
+ id: "safari_ios",
+ version: "13.4",
+};
+
+const TARGET_BROWSERS = [
+ FIREFOX_1,
+ FIREFOX_60,
+ FIREFOX_69,
+ FIREFOX_MOBILE,
+ CHROME_81,
+ CHROME_ANDROID,
+ SAFARI_13,
+ SAFARI_MOBILE,
+ EDGE_81,
+];
+
+const ISSUE_USER_SELECT = {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY_ALIASES,
+ property: "user-select",
+ aliases: ["-moz-user-select"],
+ url: "https://developer.mozilla.org/docs/Web/CSS/user-select",
+ specUrl: "https://drafts.csswg.org/css-ui/#content-selection",
+ deprecated: false,
+ experimental: false,
+ prefixNeeded: true,
+ unsupportedBrowsers: [
+ CHROME_81,
+ CHROME_ANDROID,
+ SAFARI_13,
+ SAFARI_MOBILE,
+ EDGE_81,
+ ],
+};
+
+const ISSUE_CLIP = {
+ type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY,
+ property: "clip",
+ url: "https://developer.mozilla.org/docs/Web/CSS/clip",
+ specUrl: "https://drafts.fxtf.org/css-masking/#clip-property",
+ deprecated: true,
+ experimental: false,
+ unsupportedBrowsers: [],
+};
+
+async function testNodeCssIssues(selector, walker, compatibility, expected) {
+ const node = await walker.querySelector(walker.rootNode, selector);
+ const cssCompatibilityIssues = await compatibility.getNodeCssIssues(
+ node,
+ TARGET_BROWSERS
+ );
+ info("Ensure result is correct");
+ Assert.deepEqual(
+ cssCompatibilityIssues,
+ expected,
+ "Expected CSS browser compat data is correct."
+ );
+}
+
+add_task(async function () {
+ const { inspector, walker, target } = await initInspectorFront(URL);
+ const compatibility = await inspector.getCompatibilityFront();
+
+ info('Test CSS properties linked with the "div" tag');
+ await testNodeCssIssues("div", walker, compatibility, []);
+
+ info('Test CSS properties linked with class "class-user-select"');
+ await testNodeCssIssues(".class-user-select", walker, compatibility, [
+ ISSUE_USER_SELECT,
+ ]);
+
+ info("Test CSS properties linked with multiple classes and id");
+ await testNodeCssIssues(
+ "div#id-clip.class-clip.class-user-select",
+ walker,
+ compatibility,
+ [ISSUE_CLIP, ISSUE_USER_SELECT]
+ );
+
+ info("Repeated incompatible CSS rule should be only reported once");
+ await testNodeCssIssues(".duplicate", walker, compatibility, [ISSUE_CLIP]);
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_connectToFrame.js b/devtools/server/tests/browser/browser_connectToFrame.js
new file mode 100644
index 0000000000..568eb1acc1
--- /dev/null
+++ b/devtools/server/tests/browser/browser_connectToFrame.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test `connectToFrame` method
+ */
+
+"use strict";
+
+const {
+ connectToFrame,
+} = require("resource://devtools/server/connectors/frame-connector.js");
+
+add_task(async function () {
+ // Create a minimal browser with a message manager
+ const browser = document.createXULElement("browser");
+ browser.setAttribute("type", "content");
+ document.body.appendChild(browser);
+
+ await TestUtils.waitForCondition(
+ () => browser.browsingContext.currentWindowGlobal,
+ "browser has no window global"
+ );
+
+ // Register a test actor in the child process so that we can know if and when
+ // this fake actor is destroyed.
+ await SpecialPowers.spawn(browser, [], () => {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ const {
+ ActorRegistry,
+ } = require("resource://devtools/server/actors/utils/actor-registry.js");
+
+ DevToolsServer.init();
+
+ const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+ class ConnectToFrameTestActor extends Actor {
+ constructor(conn, tab) {
+ super(conn, { typeName: "connectToFrameTest", methods: [] });
+ dump("instantiate test actor\n");
+ this.requestTypes = {
+ hello: this.hello,
+ };
+ }
+ hello() {
+ return { msg: "world" };
+ }
+
+ destroy() {
+ SpecialPowers.notifyObserversInParentProcess(
+ null,
+ "devtools-test-actor-destroyed",
+ ""
+ );
+ }
+ }
+
+ ActorRegistry.addTargetScopedActor(
+ {
+ constructorName: "ConnectToFrameTestActor",
+ constructorFun: ConnectToFrameTestActor,
+ },
+ "connectToFrameTestActor"
+ );
+ });
+
+ // Instantiate a minimal server
+ DevToolsServer.init();
+ if (!DevToolsServer.createRootActor) {
+ DevToolsServer.registerAllActors();
+ }
+
+ async function initAndCloseFirstClient() {
+ // Fake a first connection to a browser
+ const transport = DevToolsServer.connectPipe();
+ const conn = transport._serverConnection;
+ const client = new DevToolsClient(transport);
+ const actor = await connectToFrame(conn, browser);
+ ok(actor.connectToFrameTestActor, "Got the test actor");
+
+ // Ensure sending at least one request to our actor,
+ // otherwise it won't be instantiated, nor be destroyed...
+ await client.request({
+ to: actor.connectToFrameTestActor,
+ type: "hello",
+ });
+
+ // Connect a second client in parallel to assert that it received a distinct set of
+ // target actors
+ await initAndCloseSecondClient(actor.connectToFrameTestActor);
+
+ ok(
+ DevToolsServer.initialized,
+ "DevToolsServer isn't destroyed until all clients are disconnected"
+ );
+
+ // Ensure that our test actor got cleaned up;
+ // its destroy method should be called
+ const onActorDestroyed = TestUtils.topicObserved(
+ "devtools-test-actor-destroyed"
+ );
+
+ // Then close the client. That should end up cleaning our test actor
+ await client.close();
+
+ await onActorDestroyed;
+
+ // This test loads a frame in the parent process, so that we end up sharing the same
+ // DevToolsServer instance
+ ok(
+ !DevToolsServer.initialized,
+ "DevToolsServer is destroyed when all clients are disconnected"
+ );
+ }
+
+ async function initAndCloseSecondClient(firstActor) {
+ // Then fake a second one, that should spawn a new set of target-scoped actors
+ const transport = DevToolsServer.connectPipe();
+ const conn = transport._serverConnection;
+ const client = new DevToolsClient(transport);
+ const actor = await connectToFrame(conn, browser);
+ ok(
+ actor.connectToFrameTestActor,
+ "Got a test actor for the second connection"
+ );
+ isnot(
+ actor.connectToFrameTestActor,
+ firstActor,
+ "We get different actor instances between two connections"
+ );
+ return client.close();
+ }
+
+ await initAndCloseFirstClient();
+
+ DevToolsServer.destroy();
+ browser.remove();
+});
diff --git a/devtools/server/tests/browser/browser_debugger_server.js b/devtools/server/tests/browser/browser_debugger_server.js
new file mode 100644
index 0000000000..8b36076b34
--- /dev/null
+++ b/devtools/server/tests/browser/browser_debugger_server.js
@@ -0,0 +1,198 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test basic features of DevToolsServer
+
+add_task(async function () {
+ // When running some other tests before, they may not destroy the main server.
+ // Do it manually before running our tests.
+ if (DevToolsServer.initialized) {
+ DevToolsServer.destroy();
+ }
+
+ await testDevToolsServerInitialized();
+ await testDevToolsServerKeepAlive();
+});
+
+async function testDevToolsServerInitialized() {
+ const tab = await addTab("data:text/html;charset=utf-8,foo");
+
+ ok(
+ !DevToolsServer.initialized,
+ "By default, the DevToolsServer isn't initialized in parent process"
+ );
+ await assertServerInitialized(
+ tab,
+ false,
+ "By default, the DevToolsServer isn't initialized not in content process"
+ );
+ await assertDevToolsOpened(
+ tab,
+ false,
+ "By default, the DevTools are reported as closed"
+ );
+
+ const commands = await CommandsFactory.forTab(tab);
+
+ ok(
+ DevToolsServer.initialized,
+ "Creating the commands will initialize the DevToolsServer in parent process"
+ );
+ await assertServerInitialized(
+ tab,
+ false,
+ "Creating the commands isn't enough to initialize the DevToolsServer in content process"
+ );
+ await assertDevToolsOpened(
+ tab,
+ false,
+ "DevTools are still reported as closed after having created the commands"
+ );
+
+ await commands.targetCommand.startListening();
+
+ await assertServerInitialized(
+ tab,
+ true,
+ "Initializing the TargetCommand will initialize the DevToolsServer in content process"
+ );
+ await assertDevToolsOpened(
+ tab,
+ true,
+ "Initializing the TargetCommand will start reporting the DevTools as opened"
+ );
+
+ await commands.destroy();
+
+ // Disconnecting the client will remove all connections from both server, in parent and content process.
+ ok(
+ !DevToolsServer.initialized,
+ "Destroying the commands destroys the DevToolsServer in the parent process"
+ );
+ await assertServerInitialized(
+ tab,
+ false,
+ "But destroying the commands ends up destroying the DevToolsServer in the content process"
+ );
+ await assertDevToolsOpened(
+ tab,
+ false,
+ "Destroying the commands will report DevTools as being closed"
+ );
+
+ gBrowser.removeCurrentTab();
+ DevToolsServer.destroy();
+}
+
+async function testDevToolsServerKeepAlive() {
+ const tab = await addTab("data:text/html;charset=utf-8,foo");
+
+ await assertServerInitialized(
+ tab,
+ false,
+ "Server not started in content process"
+ );
+ await assertDevToolsOpened(tab, false, "DevTools are reported as closed");
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ await assertServerInitialized(tab, true, "Server started in content process");
+ await assertDevToolsOpened(tab, true, "DevTools are reported as opened");
+
+ info("Set DevToolsServer.keepAlive to true in the content process");
+ DevToolsServer.keepAlive = true;
+ await setContentServerKeepAlive(tab, true);
+
+ info("Destroy the commands, the content server should be kept alive");
+ await commands.destroy();
+
+ await assertServerInitialized(
+ tab,
+ true,
+ "Server still running in content process"
+ );
+ await assertDevToolsOpened(
+ tab,
+ false,
+ "DevTools are reported as close, even if the server is still running because there is no more client connected"
+ );
+
+ ok(
+ DevToolsServer.initialized,
+ "Destroying the commands never destroys the DevToolsServer in the parent process when keepAlive is true"
+ );
+
+ info("Set DevToolsServer.keepAlive back to false");
+ DevToolsServer.keepAlive = false;
+ await setContentServerKeepAlive(tab, false);
+
+ info("Create and destroy a commands again");
+ const newCommands = await CommandsFactory.forTab(tab);
+ await newCommands.targetCommand.startListening();
+
+ await newCommands.destroy();
+
+ await assertServerInitialized(
+ tab,
+ false,
+ "Server stopped in content process"
+ );
+ await assertDevToolsOpened(
+ tab,
+ false,
+ "DevTools are reported as closed after destroying the second commands"
+ );
+
+ ok(
+ !DevToolsServer.initialized,
+ "When turning keepAlive to false, the server in the parent process is destroyed"
+ );
+
+ gBrowser.removeCurrentTab();
+ DevToolsServer.destroy();
+}
+
+async function assertServerInitialized(tab, expected, message) {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [expected, message],
+ function (_expected, _message) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ is(DevToolsServer.initialized, _expected, _message);
+ }
+ );
+}
+
+async function assertDevToolsOpened(tab, expected, message) {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [expected, message],
+ function (_expected, _message) {
+ is(ChromeUtils.isDevToolsOpened(), _expected, _message);
+ }
+ );
+}
+
+async function setContentServerKeepAlive(tab, keepAlive, message) {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [keepAlive],
+ function (_keepAlive) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ DevToolsServer.keepAlive = _keepAlive;
+ }
+ );
+}
diff --git a/devtools/server/tests/browser/browser_document_devtools_basics.js b/devtools/server/tests/browser/browser_document_devtools_basics.js
new file mode 100644
index 0000000000..1d15420559
--- /dev/null
+++ b/devtools/server/tests/browser/browser_document_devtools_basics.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Document the basics of DevTools backend via Fronts in a test.
+ */
+
+"use strict";
+
+const TEST_URL = "data:text/html,new-tab";
+
+add_task(async () => {
+ // Allow logging all RDP packets
+ await pushPref("devtools.debugger.log", true);
+ // Really all of them
+ await pushPref("devtools.debugger.log.verbose", true);
+
+ // Instantiate a DevTools server
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ // Instantiate a client connected to this server
+ const transport = DevToolsServer.connectPipe();
+ const client = new DevToolsClient(transport);
+
+ // This will trigger some handshake with the server
+ await client.connect();
+
+ // You need to call listTabs once to retrieve the existing list of Tab Descriptor actors...
+ const tabs = await client.mainRoot.listTabs();
+
+ // ... which will let you receive the 'tabListChanged' event.
+ // This is an empty RDP packet, you need to re-call listTabs to get the full new updated list of actors.
+ const onTabListUpdated = client.mainRoot.once("tabListChanged");
+
+ // Open a new tab.
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ });
+
+ await onTabListUpdated;
+
+ // The new list of Tab descriptors should contain the newly opened tab
+ const newTabs = await client.mainRoot.listTabs();
+ is(newTabs.length, tabs.length + 1);
+
+ const tabDescriptorActor = newTabs.pop();
+ is(tabDescriptorActor.url, TEST_URL);
+
+ // Query the Tab Descriptor actor to retrieve its related Watcher actor.
+ // Each Descriptor actor has a dedicated watcher which will be scoped to the context of the descriptor.
+ // Here the watcher will focus on the related tab.
+ const watcherActor = await tabDescriptorActor.getWatcher();
+
+ // The call to Watcher Actor's watchTargets will emit target-available-form RDP events.
+ // One per available target. It will emit one for each immediatly available target,
+ // but also for any available later. That, until you call unwatchTarget method.
+ //
+ // Here I'm listening to "target-available" to get a Front instance, which helps call RDP methods.
+ // But this isn't an RDP event. This is a frontend-only thing.
+ const onTopTargetAvailable = watcherActor.once("target-available");
+
+ // watchTargets accepts "frame", "process" and "worker"
+ // When debugging a web page you want to listen to frame and worker targets.
+ // "frame" would better be named "WindowGlobal" as it will notify you about all the WindowGlobal of the page.
+ // Each top level documents and any iframe documents will have a related WindowGlobal,
+ // if any of these documents navigate, a new WindowGlobal will be instantiated.
+ // If you care about workers, listen to worker targets as well.
+ await watcherActor.watchTargets("frame");
+
+ // This is a trivial example so we have a unique WindowGlobal target for the top level document
+ const topTarget = await onTopTargetAvailable;
+ is(topTarget.url, TEST_URL);
+
+ // Similarly to watchTarget, the next call to watchResources will emit new resources right away as well as later.
+ const onConsoleMessages = topTarget.once("resource-available-form");
+
+ // If you want to observe anything, you have to use Watcher Actor's watchrResources API.
+ // The list of all available resources is here:
+ // https://searchfox.org/mozilla-central/source/devtools/server/actors/resources/index.js#9
+ // And you might have a look at each ResourceWatcher subclass to learn more about the fields exposed by each resource type:
+ // https://searchfox.org/mozilla-central/source/devtools/server/actors/resources
+ await watcherActor.watchResources(["console-message"]);
+
+ // You may use many useful actors on each target actor, like console, thread, ...
+ // You can get the full list of available actors in:
+ // https://searchfox.org/mozilla-central/source/devtools/server/actors/utils/actor-registry.js#176
+ // And then look into the mentioned path for implementation.
+ const webConsoleActor = await topTarget.getFront("console");
+
+ // Call the Console API in order to force emitting a console-message resource
+ await webConsoleActor.evaluateJSAsync({ text: "console.log('42')" });
+
+ // Wait for the related console-message resource
+ const resources = await onConsoleMessages;
+
+ // Note that resource-available-form comes with a "resources" attribute which is an array of resources
+ // which may contain various resource types.
+ is(resources[0].message.arguments[0], "42");
+
+ await client.close();
+});
diff --git a/devtools/server/tests/browser/browser_document_rdp_basics.js b/devtools/server/tests/browser/browser_document_rdp_basics.js
new file mode 100644
index 0000000000..552837ff7c
--- /dev/null
+++ b/devtools/server/tests/browser/browser_document_rdp_basics.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Document the basics of RDP packets via a test.
+ */
+
+"use strict";
+
+const TEST_URL = "data:text/html,new-tab";
+
+add_task(async () => {
+ // Allow logging all RDP packets
+ await pushPref("devtools.debugger.log", true);
+ // Really all of them
+ await pushPref("devtools.debugger.log.verbose", true);
+
+ // Instantiate a DevTools server
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ // Instantiate a client connected to this server
+ const transport = DevToolsServer.connectPipe();
+ const client = new DevToolsClient(transport);
+
+ // This will trigger some handshake with the server
+ await client.connect();
+
+ // Ignore this gross hack, this is to be able to emit raw RDP packet via client.request
+ // (a Front is instantiated by DevToolsClient which would be confused with us sending
+ // RDP packets for the Root actor)
+ client.mainRoot.destroy();
+
+ // You need to call listTabs once to retrieve the existing list of Tab Descriptor actors...
+ const { tabs } = await client.request({ to: "root", type: "listTabs" });
+
+ // ... which will let you receive the 'tabListChanged' event.
+ // This is an empty RDP packet, you need to re-call listTabs to get the full new updated list of actors.
+ const onTabListUpdated = client.once("tabListChanged");
+
+ // Open a new tab.
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_URL,
+ });
+
+ await onTabListUpdated;
+
+ // The new list of Tab descriptors should contain the newly opened tab
+ const { tabs: newTabs } = await client.request({
+ to: "root",
+ type: "listTabs",
+ });
+ is(newTabs.length, tabs.length + 1);
+
+ const tabDescriptorActor = newTabs.pop();
+ is(tabDescriptorActor.url, TEST_URL);
+
+ // Query the Tab Descriptor actor to retrieve its related Watcher actor.
+ // Each Descriptor actor has a dedicated watcher which will be scoped to the context of the descriptor.
+ // Here the watcher will focus on the related tab.
+ //
+ // You want to pass isServerTargetSwitchingEnabled set to true in order to be notified about the top level document,
+ // as well as navigations to subsequent documents.
+ const watcherActor = await client.request({
+ to: tabDescriptorActor.actor,
+ type: "getWatcher",
+ isServerTargetSwitchingEnabled: true,
+ });
+
+ // The call to Watcher Actor's watchTargets will emit target-available-form RDP events.
+ // One per available target. It will emit one for each immediatly available target,
+ // but also for any available later. That, until you call unwatchTarget method.
+ const onTopTargetAvailable = client.once("target-available-form");
+
+ // watchTargets accepts "frame", "process" and "worker"
+ // When debugging a web page you want to listen to frame and worker targets.
+ // "frame" would better be named "WindowGlobal" as it will notify you about all the WindowGlobal of the page.
+ // Each top level documents and any iframe documents will have a related WindowGlobal,
+ // if any of these documents navigate, a new WindowGlobal will be instantiated.
+ // If you care about workers, listen to worker targets as well.
+ await client.request({
+ to: watcherActor.actor,
+ type: "watchTargets",
+ targetType: "frame",
+ });
+
+ // This is a trivial example so we have a unique WindowGlobal target for the top level document
+ const { target: topTarget } = await onTopTargetAvailable;
+ is(topTarget.url, TEST_URL);
+
+ // Similarly to watchTarget, the next call to watchResources will emit new resources right away as well as later.
+ const onConsoleMessages = client.once("resource-available-form");
+
+ // If you want to observe anything, you have to use Watcher Actor's watchrResources API.
+ // The list of all available resources is here:
+ // https://searchfox.org/mozilla-central/source/devtools/server/actors/resources/index.js#9
+ // And you might have a look at each ResourceWatcher subclass to learn more about the fields exposed by each resource type:
+ // https://searchfox.org/mozilla-central/source/devtools/server/actors/resources
+ await client.request({
+ to: watcherActor.actor,
+ type: "watchResources",
+ resourceTypes: ["console-message"],
+ });
+
+ // You may use many useful actors on each target actor, like console, thread, ...
+ // You can get the full list of available actors in:
+ // https://searchfox.org/mozilla-central/source/devtools/server/actors/utils/actor-registry.js#176
+ // And then look into the mentioned path for implementation.
+ //
+ // The "target form" contains the list of all these actor IDs
+ const webConsoleActorID = topTarget.consoleActor;
+
+ // Call the Console API in order to force emitting a console-message resource
+ await client.request({
+ to: webConsoleActorID,
+ type: "evaluateJSAsync",
+ text: "console.log('42')",
+ });
+
+ // Wait for the related console-message resource
+ const { resources } = await onConsoleMessages;
+
+ // Note that resource-available-form comes with a "resources" attribute which is an array of resources
+ // which may contain various resource types.
+ is(resources[0].message.arguments[0], "42");
+
+ await client.close();
+});
diff --git a/devtools/server/tests/browser/browser_getProcess.js b/devtools/server/tests/browser/browser_getProcess.js
new file mode 100644
index 0000000000..30c9fff589
--- /dev/null
+++ b/devtools/server/tests/browser/browser_getProcess.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test `RootActor.getProcess` method
+ */
+
+"use strict";
+
+add_task(async () => {
+ let client, tab;
+
+ function connect() {
+ // Fake a first connection to the content process
+ const transport = DevToolsServer.connectPipe();
+ client = new DevToolsClient(transport);
+ return client.connect();
+ }
+
+ async function listProcess() {
+ const onNewProcess = new Promise(resolve => {
+ // Call listProcesses in order to start receiving new process notifications
+ client.mainRoot.on("processListChanged", function listener() {
+ client.off("processListChanged", listener);
+ ok(true, "Received processListChanged event");
+ resolve();
+ });
+ });
+ await client.mainRoot.listProcesses();
+ await createNewProcess();
+ return onNewProcess;
+ }
+
+ async function createNewProcess() {
+ tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "data:text/html,new-process",
+ forceNewProcess: true,
+ });
+ }
+
+ async function getProcess() {
+ // Note that we can't assert process count as the number of processes
+ // is affected by previous tests.
+ const processes = await client.mainRoot.listProcesses();
+ const { osPid } = tab.linkedBrowser.browsingContext.currentWindowGlobal;
+ const descriptor = processes.find(process => process.id == osPid);
+ ok(descriptor, "Got the new process descriptor");
+
+ // Connect to the first content process available
+ const content = processes.filter(p => !p.isParentProcessDescriptor)[0];
+
+ const processDescriptor = await client.mainRoot.getProcess(content.id);
+ const front = await processDescriptor.getTarget();
+ const targetForm = front.targetForm;
+ ok(targetForm.consoleActor, "Got the console actor");
+ ok(targetForm.threadActor, "Got the thread actor");
+
+ // Process target are no longer really used/supported beyond listing their workers
+ // from RootFront.
+ const { workers } = await front.listWorkers();
+ is(workers.length, 0, "listWorkers worked and reported no workers");
+
+ return [front, content.id];
+ }
+
+ // Assert that calling client.getProcess against the same process id is
+ // returning the same actor.
+ async function getProcessAgain(firstTargetFront, id) {
+ const processDescriptor = await client.mainRoot.getProcess(id);
+ const front = await processDescriptor.getTarget();
+ is(
+ front,
+ firstTargetFront,
+ "Second call to getProcess with the same id returns the same form"
+ );
+ }
+
+ function processScript() {
+ /* eslint-env mozilla/process-script */
+ const listener = function () {
+ Services.obs.removeObserver(listener, "devtools:loader:destroy");
+ sendAsyncMessage("test:getProcess-destroy", null);
+ };
+ Services.obs.addObserver(listener, "devtools:loader:destroy");
+ }
+
+ async function closeClient() {
+ const onLoaderDestroyed = new Promise(done => {
+ const processListener = function () {
+ Services.ppmm.removeMessageListener(
+ "test:getProcess-destroy",
+ processListener
+ );
+ done();
+ };
+ Services.ppmm.addMessageListener(
+ "test:getProcess-destroy",
+ processListener
+ );
+ });
+ const script = `data:,(${encodeURI(processScript)})()`;
+ Services.ppmm.loadProcessScript(script, true);
+ await client.close();
+
+ await onLoaderDestroyed;
+ Services.ppmm.removeDelayedProcessScript(script);
+ info("Loader destroyed in the content process");
+ }
+
+ // Instantiate a minimal server
+ DevToolsServer.init();
+ DevToolsServer.allowChromeProcess = true;
+ if (!DevToolsServer.createRootActor) {
+ DevToolsServer.registerAllActors();
+ }
+
+ await connect();
+ await listProcess();
+
+ const [front, contentId] = await getProcess();
+
+ await getProcessAgain(front, contentId);
+
+ await closeClient();
+
+ BrowserTestUtils.removeTab(tab);
+ DevToolsServer.destroy();
+});
diff --git a/devtools/server/tests/browser/browser_inspector-anonymous.js b/devtools/server/tests/browser/browser_inspector-anonymous.js
new file mode 100644
index 0000000000..024b7af1bb
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-anonymous.js
@@ -0,0 +1,204 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for Bug 777674
+
+add_task(async function () {
+ await SpecialPowers.pushPermissions([
+ { type: "allowXULXBL", allow: true, context: MAIN_DOMAIN },
+ ]);
+
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ await testXBLAnonymousInHTMLDocument(walker);
+ await testNativeAnonymous(walker);
+ await testNativeAnonymousStartingNode(walker);
+
+ await testPseudoElements(walker);
+ await testEmptyWithPseudo(walker);
+ await testShadowAnonymous(walker);
+});
+
+async function testXBLAnonymousInHTMLDocument(walker) {
+ info("Testing XBL anonymous in an HTML document.");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ const XUL_NS =
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+ const rawToolbarbutton = content.document.createElementNS(
+ XUL_NS,
+ "toolbarbutton"
+ );
+ content.document.documentElement.appendChild(rawToolbarbutton);
+ });
+
+ const toolbarbutton = await walker.querySelector(
+ walker.rootNode,
+ "toolbarbutton"
+ );
+ const children = await walker.children(toolbarbutton);
+
+ is(toolbarbutton.numChildren, 0, "XBL content is not visible in HTML doc");
+ is(children.nodes.length, 0, "XBL content is not returned in HTML doc");
+}
+
+async function testNativeAnonymous(walker) {
+ info("Testing native anonymous content with walker.");
+
+ const select = await walker.querySelector(walker.rootNode, "select");
+ const children = await walker.children(select);
+
+ is(select.numChildren, 2, "No native anon content for form control");
+ is(children.nodes.length, 2, "No native anon content for form control");
+}
+
+async function testNativeAnonymousStartingNode(walker) {
+ info("Tests attaching an element that a walker can't see.");
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[walker.actorID]],
+ async function (actorID) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+
+ const {
+ DocumentWalker,
+ } = require("resource://devtools/server/actors/inspector/document-walker.js");
+ const nodeFilterConstants = require("resource://devtools/shared/dom-node-filter-constants.js");
+
+ const docwalker = new DocumentWalker(
+ content.document.querySelector("select"),
+ content,
+ {
+ filter: () => {
+ return nodeFilterConstants.FILTER_ACCEPT;
+ },
+ }
+ );
+ const scrollbar = docwalker.lastChild();
+ is(scrollbar.tagName, "scrollbar", "An anonymous child has been fetched");
+
+ // Convert actorID to current compartment string otherwise
+ // searchAllConnectionsForActor is confused and won't find the actor.
+ actorID = String(actorID);
+ const serverWalker = DevToolsServer.searchAllConnectionsForActor(actorID);
+ const node = await serverWalker.attachElement(scrollbar);
+
+ ok(node, "A response has arrived");
+ ok(node.node, "A node is in the response");
+ is(
+ node.node.rawNode.tagName,
+ "SELECT",
+ "The node has changed to a parent that the walker recognizes"
+ );
+ }
+ );
+}
+
+async function testPseudoElements(walker) {
+ info("Testing pseudo elements with walker.");
+
+ // Markup looks like: <div><::before /><span /><::after /></div>
+ const pseudo = await walker.querySelector(walker.rootNode, "#pseudo");
+ const children = await walker.children(pseudo);
+
+ is(
+ pseudo.numChildren,
+ 1,
+ "::before/::after are not counted if there is a child"
+ );
+ is(children.nodes.length, 3, "Correct number of children");
+
+ const before = children.nodes[0];
+ ok(before.isAnonymous, "Child is anonymous");
+ ok(before._form.isNativeAnonymous, "Child is native anonymous");
+
+ const span = children.nodes[1];
+ ok(!span.isAnonymous, "Child is not anonymous");
+
+ const after = children.nodes[2];
+ ok(after.isAnonymous, "Child is anonymous");
+ ok(after._form.isNativeAnonymous, "Child is native anonymous");
+}
+
+async function testEmptyWithPseudo(walker) {
+ info("Testing elements with no childrent, except for pseudos.");
+
+ info("Checking an element whose only child is a pseudo element");
+ const pseudo = await walker.querySelector(walker.rootNode, "#pseudo-empty");
+ const children = await walker.children(pseudo);
+
+ is(
+ pseudo.numChildren,
+ 1,
+ "::before/::after are is counted if there are no other children"
+ );
+ is(children.nodes.length, 1, "Correct number of children");
+
+ const before = children.nodes[0];
+ ok(before.isAnonymous, "Child is anonymous");
+ ok(before._form.isNativeAnonymous, "Child is native anonymous");
+}
+
+async function testShadowAnonymous(walker) {
+ info("Testing shadow DOM content.");
+
+ const host = await walker.querySelector(walker.rootNode, "#shadow");
+ const children = await walker.children(host);
+
+ // #shadow-root, ::before, light dom
+ is(host.numChildren, 3, "Children of the shadow root are counted");
+ is(children.nodes.length, 3, "Children returned from walker");
+
+ const before = children.nodes[1];
+ is(
+ before._form.nodeName,
+ "_moz_generated_content_before",
+ "Should be the ::before pseudo-element"
+ );
+ ok(before.isAnonymous, "::before is anonymous");
+ ok(before._form.isNativeAnonymous, "::before is native anonymous");
+ info(JSON.stringify(before._form));
+
+ const shadow = children.nodes[0];
+ const shadowChildren = await walker.children(shadow);
+ // <h3>...</h3>, <select multiple></select>
+ is(shadow.numChildren, 2, "Children of the shadow root are counted");
+ is(shadowChildren.nodes.length, 2, "Children returned from walker");
+
+ // <h3>Shadow <em>DOM</em></h3>
+ const shadowChild1 = shadowChildren.nodes[0];
+ ok(!shadowChild1.isAnonymous, "Shadow child is not anonymous");
+ ok(
+ !shadowChild1._form.isNativeAnonymous,
+ "Shadow child is not native anonymous"
+ );
+
+ const shadowSubChildren = await walker.children(shadowChild1);
+ is(shadowChild1.numChildren, 2, "Subchildren of the shadow root are counted");
+ is(shadowSubChildren.nodes.length, 2, "Subchildren are returned from walker");
+
+ // <em>DOM</em>
+ const shadowSubChild = shadowSubChildren.nodes[1];
+ ok(
+ !shadowSubChild.isAnonymous,
+ "Subchildren of shadow root are not anonymous"
+ );
+ ok(
+ !shadowSubChild._form.isNativeAnonymous,
+ "Subchildren of shadow root is not native anonymous"
+ );
+
+ // <select multiple></select>
+ const shadowChild2 = shadowChildren.nodes[1];
+ ok(!shadowChild2.isAnonymous, "Child is anonymous");
+ ok(!shadowChild2._form.isNativeAnonymous, "Child is not native anonymous");
+}
diff --git a/devtools/server/tests/browser/browser_inspector-iframe.js b/devtools/server/tests/browser/browser_inspector-iframe.js
new file mode 100644
index 0000000000..e9c3fd93a1
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-iframe.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BYPASS_WALKERFRONT_CHILDREN_IFRAME_GUARD_PREF =
+ "devtools.testing.bypass-walker-children-iframe-guard";
+
+add_task(async function testIframe() {
+ info("Check that dedicated walker is used for retrieving iframe children");
+
+ const TEST_URI = `https://example.com/document-builder.sjs?html=${encodeURIComponent(`
+ <h1>Test iframe</h1>
+ <iframe src="https://example.com/document-builder.sjs?html=Hello"></iframe>
+`)}`;
+
+ const { walker } = await initInspectorFront(TEST_URI);
+ const iframeNodeFront = await walker.querySelector(walker.rootNode, "iframe");
+
+ is(
+ iframeNodeFront.useChildTargetToFetchChildren,
+ isEveryFrameTargetEnabled(),
+ "useChildTargetToFetchChildren has expected value"
+ );
+ is(
+ iframeNodeFront.numChildren,
+ 1,
+ "numChildren is set to 1 (for the #document node)"
+ );
+
+ const res = await walker.children(iframeNodeFront);
+ is(
+ res.nodes.length,
+ 1,
+ "Retrieving the iframe children return an array with one element"
+ );
+ const documentNodeFront = res.nodes[0];
+ is(
+ documentNodeFront.nodeName,
+ "#document",
+ "The child is the #document element"
+ );
+ if (isEveryFrameTargetEnabled()) {
+ Assert.notStrictEqual(
+ documentNodeFront.walkerFront,
+ walker,
+ "The child walker is different from the top level document one when EFT is enabled"
+ );
+ }
+ is(
+ documentNodeFront.parentNode(),
+ iframeNodeFront,
+ "The child parent was set to the original iframe nodeFront"
+ );
+});
+
+add_task(async function testIframeBlockedByCSP() {
+ info("Check that iframe blocked by CSP don't have any children");
+
+ const TEST_URI = `https://example.com/document-builder.sjs?html=${encodeURIComponent(`
+ <h1>Test CSP-blocked iframe</h1>
+ <iframe src="https://example.org/document-builder.sjs?html=Hello"></iframe>
+`)}&headers=content-security-policy:default-src 'self'`;
+
+ const { walker } = await initInspectorFront(TEST_URI);
+ const iframeNodeFront = await walker.querySelector(walker.rootNode, "iframe");
+
+ is(
+ iframeNodeFront.useChildTargetToFetchChildren,
+ false,
+ "useChildTargetToFetchChildren is false"
+ );
+ is(iframeNodeFront.numChildren, 0, "numChildren is set to 0");
+
+ info("Test calling WalkerFront#children with the safe guard removed");
+ await pushPref(BYPASS_WALKERFRONT_CHILDREN_IFRAME_GUARD_PREF, true);
+
+ let res = await walker.children(iframeNodeFront);
+ is(
+ res.nodes.length,
+ 0,
+ "Retrieving the iframe children return an empty array"
+ );
+
+ info("Test calling WalkerFront#children again, but with the safe guard");
+ Services.prefs.clearUserPref(BYPASS_WALKERFRONT_CHILDREN_IFRAME_GUARD_PREF);
+ res = await walker.children(iframeNodeFront);
+ is(
+ res.nodes.length,
+ 0,
+ "Retrieving the iframe children return an empty array"
+ );
+});
diff --git a/devtools/server/tests/browser/browser_inspector-insert.js b/devtools/server/tests/browser/browser_inspector-insert.js
new file mode 100644
index 0000000000..d3f2ea482d
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-insert.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ await testRearrange(walker);
+ await testInsertInvalidInput(walker);
+});
+
+async function testRearrange(walker) {
+ const longlist = await walker.querySelector(walker.rootNode, "#longlist");
+ let children = await walker.children(longlist);
+ const nodeA = children.nodes[0];
+ is(nodeA.id, "a", "Got the expected node.");
+
+ // Move nodeA to the end of the list.
+ await walker.insertBefore(nodeA, longlist, null);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ ok(
+ !content.document.querySelector("#a").nextSibling,
+ "a should now be at the end of the list."
+ );
+ });
+
+ children = await walker.children(longlist);
+ is(
+ nodeA,
+ children.nodes[children.nodes.length - 1],
+ "a should now be the last returned child."
+ );
+
+ // Now move it to the middle of the list.
+ const nextNode = children.nodes[13];
+ await walker.insertBefore(nodeA, longlist, nextNode);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[nextNode.actorID]],
+ async function (actorID) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ const {
+ DocumentWalker,
+ } = require("resource://devtools/server/actors/inspector/document-walker.js");
+ const sibling = new DocumentWalker(
+ content.document.querySelector("#a"),
+ content
+ ).nextSibling();
+ // Convert actorID to current compartment string otherwise
+ // searchAllConnectionsForActor is confused and won't find the actor.
+ actorID = String(actorID);
+ const nodeActor = DevToolsServer.searchAllConnectionsForActor(actorID);
+ is(
+ sibling,
+ nodeActor.rawNode,
+ "Node should match the expected next node."
+ );
+ }
+ );
+
+ children = await walker.children(longlist);
+ is(nodeA, children.nodes[13], "a should be where we expect it.");
+ is(nextNode, children.nodes[14], "next node should be where we expect it.");
+}
+
+async function testInsertInvalidInput(walker) {
+ const longlist = await walker.querySelector(walker.rootNode, "#longlist");
+ const children = await walker.children(longlist);
+ const nodeA = children.nodes[0];
+ const nextSibling = children.nodes[1];
+
+ // Now move it to the original location and make sure no mutation happens.
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[longlist.actorID]],
+ async function (actorID) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ // Convert actorID to current compartment string otherwise
+ // searchAllConnectionsForActor is confused and won't find the actor.
+ actorID = String(actorID);
+ const nodeActor = DevToolsServer.searchAllConnectionsForActor(actorID);
+ content.hasMutated = false;
+ content.observer = new content.MutationObserver(() => {
+ content.hasMutated = true;
+ });
+ content.observer.observe(nodeActor.rawNode, {
+ childList: true,
+ });
+ }
+ );
+
+ await walker.insertBefore(nodeA, longlist, nodeA);
+ let hasMutated = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ const state = content.hasMutated;
+ content.hasMutated = false;
+ return state;
+ }
+ );
+ ok(!hasMutated, "hasn't mutated");
+
+ await walker.insertBefore(nodeA, longlist, nextSibling);
+ hasMutated = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ const state = content.hasMutated;
+ content.hasMutated = false;
+ return state;
+ }
+ );
+ ok(!hasMutated, "still hasn't mutated after inserting before nextSibling");
+
+ await walker.insertBefore(nodeA, longlist);
+ hasMutated = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ const state = content.hasMutated;
+ content.hasMutated = false;
+ return state;
+ }
+ );
+ ok(hasMutated, "has mutated after inserting with null sibling");
+
+ await walker.insertBefore(nodeA, longlist);
+ hasMutated = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ const state = content.hasMutated;
+ content.hasMutated = false;
+ return state;
+ }
+ );
+ ok(!hasMutated, "hasn't mutated after inserting with null sibling again");
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.observer.disconnect();
+ });
+}
diff --git a/devtools/server/tests/browser/browser_inspector-isScrollable.js b/devtools/server/tests/browser/browser_inspector-isScrollable.js
new file mode 100644
index 0000000000..e28fc01ce9
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-isScrollable.js
@@ -0,0 +1,34 @@
+/* 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 URL = MAIN_DOMAIN + "inspector-isScrollable-data.html";
+
+const CASES = [
+ { id: "body", expected: false },
+ { id: "no_children", expected: false },
+ { id: "one_child_no_overflow", expected: false },
+ { id: "margin_left_overflow", expected: true },
+ { id: "transform_overflow", expected: true },
+ { id: "nested_overflow", expected: true },
+ { id: "intermediate_overflow", expected: true },
+ { id: "multiple_overflow_at_different_depths", expected: true },
+ { id: "overflow_hidden", expected: false },
+ { id: "scrollbar_none", expected: false },
+];
+
+add_task(async function () {
+ info(
+ "Test that elements with scrollbars have a true value for isScrollable, and elements without scrollbars have a false value."
+ );
+ const { walker } = await initInspectorFront(URL);
+
+ for (const { id, expected } of CASES) {
+ info(`Checking element id ${id}.`);
+
+ const el = await walker.querySelector(walker.rootNode, `#${id}`);
+ is(el.isScrollable, expected, `${id} has expected value for isScrollable.`);
+ }
+});
diff --git a/devtools/server/tests/browser/browser_inspector-mutations-childlist.js b/devtools/server/tests/browser/browser_inspector-mutations-childlist.js
new file mode 100644
index 0000000000..6818c9c8dc
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-mutations-childlist.js
@@ -0,0 +1,282 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
+ this
+);
+
+function loadSelector(walker, selector) {
+ return walker.querySelectorAll(walker.rootNode, selector).then(nodeList => {
+ return nodeList.items();
+ });
+}
+
+function loadSelectors(walker, selectors) {
+ return Promise.all(Array.from(selectors, sel => loadSelector(walker, sel)));
+}
+
+function doMoves(movesArg) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [movesArg],
+ function (moves) {
+ function setParent(nodeSelector, newParentSelector) {
+ const node = content.document.querySelector(nodeSelector);
+ if (newParentSelector) {
+ const newParent = content.document.querySelector(newParentSelector);
+ newParent.appendChild(node);
+ } else {
+ node.remove();
+ }
+ }
+ for (const move of moves) {
+ setParent(move[0], move[1]);
+ }
+ }
+ );
+}
+
+/**
+ * Test a set of tree rearrangements and make sure they cause the expected changes.
+ */
+
+var gDummySerial = 0;
+
+function mutationTest(testSpec) {
+ return async function () {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ await loadSelectors(walker, testSpec.load || ["html"]);
+ walker.autoCleanup = !!testSpec.autoCleanup;
+ if (testSpec.preCheck) {
+ testSpec.preCheck();
+ }
+ const onMutations = walker.once("mutations");
+
+ await doMoves(testSpec.moves || []);
+
+ // Some of these moves will trigger no mutation events,
+ // so do a dummy change to the root node to trigger
+ // a mutation event anyway.
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[gDummySerial++]],
+ function (serial) {
+ content.document.documentElement.setAttribute("data-dummy", serial);
+ }
+ );
+
+ let mutations = await onMutations;
+
+ // Filter out our dummy mutation.
+ mutations = mutations.filter(change => {
+ if (change.type == "attributes" && change.attributeName == "data-dummy") {
+ return false;
+ }
+ return true;
+ });
+ await assertOwnershipTrees(walker);
+ if (testSpec.postCheck) {
+ testSpec.postCheck(walker, mutations);
+ }
+ };
+}
+
+// Verify that our dummy mutation works.
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ postCheck(walker, mutations) {
+ is(mutations.length, 0, "Dummy mutation is filtered out.");
+ },
+ })
+);
+
+// Test a simple move to a different location in the sibling list for the same
+// parent.
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ load: ["#longlist div"],
+ moves: [["#a", "#longlist"]],
+ postCheck(walker, mutations) {
+ const remove = mutations[0];
+ is(remove.type, "childList", "First mutation should be a childList.");
+ ok(!!remove.removed.length, "First mutation should be a removal.");
+ const add = mutations[1];
+ is(
+ add.type,
+ "childList",
+ "Second mutation should be a childList removal."
+ );
+ ok(!!add.added.length, "Second mutation should be an addition.");
+ const a = add.added[0];
+ is(a.id, "a", "Added node should be #a");
+ is(a.parentNode(), remove.target, "Should still be a child of longlist.");
+ is(
+ remove.target,
+ add.target,
+ "First and second mutations should be against the same node."
+ );
+ },
+ })
+);
+
+// Test a move to another location that is within our ownership tree.
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ load: ["#longlist div", "#longlist-sibling"],
+ moves: [["#a", "#longlist-sibling"]],
+ postCheck(walker, mutations) {
+ const remove = mutations[0];
+ is(remove.type, "childList", "First mutation should be a childList.");
+ ok(!!remove.removed.length, "First mutation should be a removal.");
+ const add = mutations[1];
+ is(
+ add.type,
+ "childList",
+ "Second mutation should be a childList removal."
+ );
+ ok(!!add.added.length, "Second mutation should be an addition.");
+ const a = add.added[0];
+ is(a.id, "a", "Added node should be #a");
+ is(a.parentNode(), add.target, "Should still be a child of longlist.");
+ is(
+ add.target.id,
+ "longlist-sibling",
+ "long-sibling should be the target."
+ );
+ },
+ })
+);
+
+// Move an unseen node with a seen parent into our ownership tree - should generate a
+// childList pair with no adds or removes.
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ load: ["#longlist"],
+ moves: [["#longlist-sibling", "#longlist"]],
+ postCheck(walker, mutations) {
+ is(mutations.length, 2, "Should generate two mutations");
+ is(mutations[0].type, "childList", "Should be childList mutations.");
+ is(mutations[0].added.length, 0, "Should have no adds.");
+ is(mutations[0].removed.length, 0, "Should have no removes.");
+ is(mutations[1].type, "childList", "Should be childList mutations.");
+ is(mutations[1].added.length, 0, "Should have no adds.");
+ is(mutations[1].removed.length, 0, "Should have no removes.");
+ },
+ })
+);
+
+// Move an unseen node with an unseen parent into our ownership tree. Should only
+// generate one childList mutation with no adds or removes.
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ load: ["#longlist div"],
+ moves: [["#longlist-sibling-firstchild", "#longlist"]],
+ postCheck(walker, mutations) {
+ is(mutations.length, 1, "Should generate two mutations");
+ is(mutations[0].type, "childList", "Should be childList mutations.");
+ is(mutations[0].added.length, 0, "Should have no adds.");
+ is(mutations[0].removed.length, 0, "Should have no removes.");
+ },
+ })
+);
+
+// Move a node between unseen nodes, should generate no mutations.
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ load: ["html"],
+ moves: [["#longlist-sibling", "#longlist"]],
+ postCheck(walker, mutations) {
+ is(mutations.length, 0, "Should generate no mutations.");
+ },
+ })
+);
+
+// Orphan a node and don't clean it up
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ load: ["#longlist div"],
+ moves: [["#longlist", null]],
+ postCheck(walker, mutations) {
+ is(mutations.length, 1, "Should generate one mutation.");
+ const change = mutations[0];
+ is(change.type, "childList", "Should be a childList.");
+ is(change.removed.length, 1, "Should have removed a child.");
+ const ownership = clientOwnershipTree(walker);
+ is(ownership.orphaned.length, 1, "Should have one orphaned subtree.");
+ is(
+ ownershipTreeSize(ownership.orphaned[0]),
+ 1 + 26 + 26,
+ "Should have orphaned longlist, and 26 children, and 26 singleTextChilds"
+ );
+ },
+ })
+);
+
+// Orphan a node, and do clean it up.
+add_task(
+ mutationTest({
+ autoCleanup: true,
+ load: ["#longlist div"],
+ moves: [["#longlist", null]],
+ postCheck(walker, mutations) {
+ is(mutations.length, 1, "Should generate one mutation.");
+ const change = mutations[0];
+ is(change.type, "childList", "Should be a childList.");
+ is(change.removed.length, 1, "Should have removed a child.");
+ const ownership = clientOwnershipTree(walker);
+ is(ownership.orphaned.length, 0, "Should have no orphaned subtrees.");
+ },
+ })
+);
+
+// Orphan a node by moving it into the tree but out of our visible subtree.
+add_task(
+ mutationTest({
+ autoCleanup: false,
+ load: ["#longlist div"],
+ moves: [["#longlist", "#longlist-sibling"]],
+ postCheck(walker, mutations) {
+ is(mutations.length, 1, "Should generate one mutation.");
+ const change = mutations[0];
+ is(change.type, "childList", "Should be a childList.");
+ is(change.removed.length, 1, "Should have removed a child.");
+ const ownership = clientOwnershipTree(walker);
+ is(ownership.orphaned.length, 1, "Should have one orphaned subtree.");
+ is(
+ ownershipTreeSize(ownership.orphaned[0]),
+ 1 + 26 + 26,
+ "Should have orphaned longlist, 26 children, and 26 singleTextChilds."
+ );
+ },
+ })
+);
+
+// Orphan a node by moving it into the tree but out of our visible subtree,
+// and clean it up.
+add_task(
+ mutationTest({
+ autoCleanup: true,
+ load: ["#longlist div"],
+ moves: [["#longlist", "#longlist-sibling"]],
+ postCheck(walker, mutations) {
+ is(mutations.length, 1, "Should generate one mutation.");
+ const change = mutations[0];
+ is(change.type, "childList", "Should be a childList.");
+ is(change.removed.length, 1, "Should have removed a child.");
+ const ownership = clientOwnershipTree(walker);
+ is(ownership.orphaned.length, 0, "Should have no orphaned subtrees.");
+ },
+ })
+);
diff --git a/devtools/server/tests/browser/browser_inspector-release.js b/devtools/server/tests/browser/browser_inspector-release.js
new file mode 100644
index 0000000000..5546da605a
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-release.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
+ this
+);
+
+add_task(async function loadNewChild() {
+ const { target, walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ let originalOwnershipSize = 0;
+ let longlist = null;
+ let firstChild = null;
+ const list = await walker.querySelectorAll(walker.rootNode, "#longlist div");
+ // Make sure we have the 26 children of longlist in our ownership tree.
+ is(list.length, 26, "Expect 26 div children.");
+ // Make sure we've read in all those children and incorporated them
+ // in our ownership tree.
+ const items = await list.items();
+ originalOwnershipSize = await assertOwnershipTrees(walker);
+
+ // Here is how the ownership tree is summed up:
+ // #document 1
+ // <html> 1
+ // <body> 1
+ // <div id=longlist> 1
+ // <div id=a>a</div> 26*2 (each child plus it's singleTextChild)
+ // ...
+ // <div id=z>z</div>
+ // -----
+ // 56
+ is(originalOwnershipSize, 56, "Correct number of items in ownership tree");
+ firstChild = items[0].actorID;
+ // Now get the longlist and release it from the ownership tree.
+ const node = await walker.querySelector(walker.rootNode, "#longlist");
+ longlist = node.actorID;
+ await walker.releaseNode(node);
+ // Our ownership size should now be 53 fewer
+ // (we forgot about #longlist + 26 children + 26 singleTextChild nodes)
+ const newOwnershipSize = await assertOwnershipTrees(walker);
+ is(
+ newOwnershipSize,
+ originalOwnershipSize - 53,
+ "Ownership tree should be lower"
+ );
+ // Now verify that some nodes have gone away
+ await checkMissing(target, longlist);
+ await checkMissing(target, firstChild);
+});
diff --git a/devtools/server/tests/browser/browser_inspector-remove.js b/devtools/server/tests/browser/browser_inspector-remove.js
new file mode 100644
index 0000000000..8338e40ea2
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-remove.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
+ this
+);
+
+add_task(async function testRemoveSubtree() {
+ const { target, walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ function ignoreNode(node) {
+ // Duplicate the walker logic to skip blank nodes...
+ return (
+ node.nodeType === content.Node.TEXT_NODE &&
+ !/[^\s]/.test(node.nodeValue)
+ );
+ }
+
+ let nextSibling = content.document.querySelector("#longlist").nextSibling;
+ while (nextSibling && ignoreNode(nextSibling)) {
+ nextSibling = nextSibling.nextSibling;
+ }
+
+ let previousSibling =
+ content.document.querySelector("#longlist").previousSibling;
+ while (previousSibling && ignoreNode(previousSibling)) {
+ previousSibling = previousSibling.previousSibling;
+ }
+ content.nextSibling = nextSibling;
+ content.previousSibling = previousSibling;
+ });
+
+ let originalOwnershipSize = 0;
+ const longlist = await walker.querySelector(walker.rootNode, "#longlist");
+ const longlistID = longlist.actorID;
+ await walker.children(longlist);
+ originalOwnershipSize = await assertOwnershipTrees(walker);
+ // Here is how the ownership tree is summed up:
+ // #document 1
+ // <html> 1
+ // <body> 1
+ // <div id=longlist> 1
+ // <div id=a>a</div> 26*2 (each child plus it's singleTextChild)
+ // ...
+ // <div id=z>z</div>
+ // -----
+ // 56
+ is(originalOwnershipSize, 56, "Correct number of items in ownership tree");
+
+ const onMutation = waitForMutation(walker, isChildList);
+ const siblings = await walker.removeNode(longlist);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[siblings.previousSibling.actorID, siblings.nextSibling.actorID]],
+ function ([previousActorID, nextActorID]) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+
+ // Convert actorID to current compartment string otherwise
+ // searchAllConnectionsForActor is confused and won't find the actor.
+ previousActorID = String(previousActorID);
+ nextActorID = String(nextActorID);
+ const previous =
+ DevToolsServer.searchAllConnectionsForActor(previousActorID);
+ const next = DevToolsServer.searchAllConnectionsForActor(nextActorID);
+
+ is(
+ previous.rawNode,
+ content.previousSibling,
+ "Should have returned the previous sibling."
+ );
+ is(
+ next.rawNode,
+ content.nextSibling,
+ "Should have returned the next sibling."
+ );
+ }
+ );
+ await onMutation;
+ // Our ownership size should now be 51 fewer (we forgot about #longlist + 26
+ // children + 26 singleTextChild nodes, but learned about #longlist's
+ // prev/next sibling)
+ const newOwnershipSize = await assertOwnershipTrees(walker);
+ is(
+ newOwnershipSize,
+ originalOwnershipSize - 51,
+ "Ownership tree should be lower"
+ );
+ // Now verify that some nodes have gone away
+ return checkMissing(target, longlistID);
+});
diff --git a/devtools/server/tests/browser/browser_inspector-retain.js b/devtools/server/tests/browser/browser_inspector-retain.js
new file mode 100644
index 0000000000..43d156675e
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-retain.js
@@ -0,0 +1,157 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
+ this
+);
+
+// Retain a node, and a second-order child (in another document, for kicks)
+// Release the parent of the top item, which should cause one retained orphan.
+
+// Then unretain the top node, which should retain the orphan.
+
+// Then change the source of the iframe, which should kill that orphan.
+
+add_task(async function testRetain() {
+ // The test does not make sense when EFT is enabled, as different documents will have
+ // different walkers.
+ if (isEveryFrameTargetEnabled()) {
+ return;
+ }
+
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ // Get the toplevel body element and retain it.
+ const bodyFront = await walker.querySelector(walker.rootNode, "body");
+ await walker.retainNode(bodyFront);
+ // Get an element in the child frame and retain it.
+ const frame = await walker.querySelector(walker.rootNode, "#childFrame");
+ const children = await walker.children(frame, { maxNodes: 1 });
+ const childDoc = children.nodes[0];
+ const childListFront = await walker.querySelector(childDoc, "#longlist");
+ const originalOwnershipSize = await assertOwnershipTrees(walker);
+ // and retain it.
+ await walker.retainNode(childListFront);
+ // OK, try releasing the parent of the first retained.
+ await walker.releaseNode(bodyFront.parentNode());
+ const clientTree = clientOwnershipTree(walker);
+
+ // That request should have freed the parent of the first retained
+ // but moved the rest into the retained orphaned tree.
+ is(
+ ownershipTreeSize(clientTree.root) +
+ ownershipTreeSize(clientTree.retained[0]) +
+ 1,
+ originalOwnershipSize,
+ "Should have only lost one item overall."
+ );
+ is(walker._retainedOrphans.size, 1, "Should have retained one orphan");
+ ok(
+ walker._retainedOrphans.has(bodyFront),
+ "Should have retained the expected node."
+ );
+ // Unretain the body, which should promote the childListFront to a retained orphan.
+ await walker.unretainNode(bodyFront);
+ await assertOwnershipTrees(walker);
+
+ is(
+ walker._retainedOrphans.size,
+ 1,
+ "Should still only have one retained orphan."
+ );
+ ok(
+ !walker._retainedOrphans.has(bodyFront),
+ "Should have dropped the body node."
+ );
+ ok(
+ walker._retainedOrphans.has(childListFront),
+ "Should have retained the child node."
+ );
+
+ // Change the source of the iframe, which should kill the retained orphan.
+ const onMutations = waitForMutation(walker, isUnretained);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.document.querySelector("#childFrame").src =
+ "data:text/html,<html>new child</html>";
+ });
+ await onMutations;
+
+ await assertOwnershipTrees(walker);
+ is(walker._retainedOrphans.size, 0, "Should have no more retained orphans.");
+});
+
+// Get a hold of a node, remove it from the doc and retain it at the same time.
+// We should always win that race (even though the mutation happens before the
+// retain request), because we haven't issued `getMutations` yet.
+add_task(async function testWinRace() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ const front = await walker.querySelector(walker.rootNode, "#a");
+ const onMutation = waitForMutation(walker, isChildList);
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ const contentNode = content.document.querySelector("#a");
+ contentNode.remove();
+ });
+ // Now wait for that mutation and retain response to come in.
+ await walker.retainNode(front);
+ await onMutation;
+
+ await assertOwnershipTrees(walker);
+ is(walker._retainedOrphans.size, 1, "Should have a retained orphan.");
+ ok(
+ walker._retainedOrphans.has(front),
+ "Should have retained our expected node."
+ );
+ await walker.unretainNode(front);
+
+ // Make sure we're clear for the next test.
+ await assertOwnershipTrees(walker);
+ is(walker._retainedOrphans.size, 0, "Should have no more retained orphans.");
+});
+
+// Same as above, but issue the request right after the 'new-mutations' event, so that
+// we *lose* the race.
+add_task(async function testLoseRace() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ const front = await walker.querySelector(walker.rootNode, "#z");
+ const onMutation = walker.once("new-mutations");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ const contentNode = content.document.querySelector("#z");
+ contentNode.remove();
+ });
+ await onMutation;
+
+ // Verify that we have an outstanding request (no good way to tell that it's a
+ // getMutations request, but there's nothing else it would be).
+ is(walker._requests.length, 1, "Should have an outstanding request.");
+ try {
+ await walker.retainNode(front);
+ ok(false, "Request should not have succeeded!");
+ } catch (err) {
+ // XXX: Switched to from ok() to todo_is() in Bug 1467712. Follow up in
+ // 1500960
+ // This is throwing because of
+ // `gInspectee.querySelector("#z").parentNode = null;` two blocks above...
+ // Even if you fix that, the test is still failing because "#a" was removed
+ // by the previous test. I am switching this to "#z" because I think that
+ // was the original intent. Still not failing with the expected error message
+ // Needs more work.
+ // ok(err, "noSuchActor", "Should have lost the race.");
+ is(
+ walker._retainedOrphans.size,
+ 0,
+ "Should have no more retained orphans."
+ );
+ // Don't re-throw the error.
+ }
+});
diff --git a/devtools/server/tests/browser/browser_inspector-search.js b/devtools/server/tests/browser/browser_inspector-search.js
new file mode 100644
index 0000000000..21cf745ce1
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-search.js
@@ -0,0 +1,347 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
+ this
+);
+
+// Test for Bug 835896
+// WalkerSearch specific tests. This is to make sure search results are
+// coming back as expected.
+// See also test_inspector-search-front.html.
+
+add_task(async function () {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-search-data.html"
+ );
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[walker.actorID]],
+ async function (actorID) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ const {
+ DocumentWalker: _documentWalker,
+ } = require("resource://devtools/server/actors/inspector/document-walker.js");
+
+ // Convert actorID to current compartment string otherwise
+ // searchAllConnectionsForActor is confused and won't find the actor.
+ actorID = String(actorID);
+ const walkerActor = DevToolsServer.searchAllConnectionsForActor(actorID);
+ const walkerSearch = walkerActor.walkerSearch;
+ const {
+ WalkerSearch,
+ WalkerIndex,
+ } = require("resource://devtools/server/actors/utils/walker-search.js");
+
+ info("Testing basic index APIs exist.");
+ const index = new WalkerIndex(walkerActor);
+ Assert.greater(
+ index.data.size,
+ 0,
+ "public index is filled after getting"
+ );
+
+ index.clearIndex();
+ ok(!index._data, "private index is empty after clearing");
+ Assert.greater(
+ index.data.size,
+ 0,
+ "public index is filled after getting"
+ );
+
+ index.destroy();
+
+ info("Testing basic search APIs exist.");
+
+ ok(walkerSearch, "walker search exists on the WalkerActor");
+ ok(walkerSearch.search, "walker search has `search` method");
+ ok(walkerSearch.index, "walker search has `index` property");
+ is(
+ walkerSearch.walker,
+ walkerActor,
+ "referencing the correct WalkerActor"
+ );
+
+ const walkerSearch2 = new WalkerSearch(walkerActor);
+ ok(walkerSearch2, "a new search instance can be created");
+ ok(walkerSearch2.search, "new search instance has `search` method");
+ ok(walkerSearch2.index, "new search instance has `index` property");
+ isnot(
+ walkerSearch2,
+ walkerSearch,
+ "new search instance differs from the WalkerActor's"
+ );
+
+ walkerSearch2.destroy();
+
+ info("Testing search with an empty query.");
+ let results = walkerSearch.search("");
+ is(results.length, 0, "No results when searching for ''");
+
+ results = walkerSearch.search(null);
+ is(results.length, 0, "No results when searching for null");
+
+ results = walkerSearch.search(undefined);
+ is(results.length, 0, "No results when searching for undefined");
+
+ results = walkerSearch.search(10);
+ is(results.length, 0, "No results when searching for 10");
+
+ const inspectee = content.document;
+ const testData = [
+ {
+ desc: "Search for tag with one result.",
+ search: "body",
+ expected: [{ node: inspectee.body, type: "tag" }],
+ },
+ {
+ desc: "Search for tag with multiple results",
+ search: "h2",
+ expected: [
+ { node: inspectee.querySelectorAll("h2")[0], type: "tag" },
+ { node: inspectee.querySelectorAll("h2")[1], type: "tag" },
+ { node: inspectee.querySelectorAll("h2")[2], type: "tag" },
+ ],
+ },
+ {
+ desc: "Search for selector with multiple results",
+ search: "body > h2",
+ expected: [
+ { node: inspectee.querySelectorAll("h2")[0], type: "selector" },
+ { node: inspectee.querySelectorAll("h2")[1], type: "selector" },
+ { node: inspectee.querySelectorAll("h2")[2], type: "selector" },
+ ],
+ },
+ {
+ desc: "Search for selector with multiple results",
+ search: ":root h2",
+ expected: [
+ { node: inspectee.querySelectorAll("h2")[0], type: "selector" },
+ { node: inspectee.querySelectorAll("h2")[1], type: "selector" },
+ { node: inspectee.querySelectorAll("h2")[2], type: "selector" },
+ ],
+ },
+ {
+ desc: "Search for selector with multiple results",
+ search: "* h2",
+ expected: [
+ { node: inspectee.querySelectorAll("h2")[0], type: "selector" },
+ { node: inspectee.querySelectorAll("h2")[1], type: "selector" },
+ { node: inspectee.querySelectorAll("h2")[2], type: "selector" },
+ ],
+ },
+ {
+ desc: "Search with multiple matches in a single tag expecting a single result",
+ search: "💩",
+ expected: [
+ { node: inspectee.getElementById("💩"), type: "attributeValue" },
+ ],
+ },
+ {
+ desc: "Search that has tag and text results",
+ search: "h1",
+ expected: [
+ { node: inspectee.querySelector("h1"), type: "tag" },
+ {
+ node: inspectee.querySelector("h1 + p").childNodes[0],
+ type: "text",
+ },
+ {
+ node: inspectee.querySelector("h1 + p > strong").childNodes[0],
+ type: "text",
+ },
+ ],
+ },
+ {
+ desc: "Search for XPath with one result",
+ search: "//strong",
+ expected: [
+ { node: inspectee.querySelector("strong"), type: "xpath" },
+ ],
+ },
+ {
+ desc: "Search for XPath with multiple results",
+ search: "//h2",
+ expected: [
+ { node: inspectee.querySelectorAll("h2")[0], type: "xpath" },
+ { node: inspectee.querySelectorAll("h2")[1], type: "xpath" },
+ { node: inspectee.querySelectorAll("h2")[2], type: "xpath" },
+ ],
+ },
+ {
+ desc: "Search for XPath via containing text",
+ search: "//*[contains(text(), 'p tag')]",
+ expected: [{ node: inspectee.querySelector("p"), type: "xpath" }],
+ },
+ {
+ desc: "Search for XPath matching text node",
+ search: "//strong/text()",
+ expected: [
+ {
+ node: inspectee.querySelector("strong").firstChild,
+ type: "xpath",
+ },
+ ],
+ },
+ {
+ desc: "Search using XPath grouping expression",
+ search: "(//*)[2]",
+ expected: [{ node: inspectee.querySelector("head"), type: "xpath" }],
+ },
+ {
+ desc: "Search using XPath function",
+ search: "id('arrows')",
+ expected: [
+ { node: inspectee.querySelector("#arrows"), type: "xpath" },
+ ],
+ },
+ ];
+
+ const isDeeply = (a, b, msg) => {
+ return is(JSON.stringify(a), JSON.stringify(b), msg);
+ };
+ for (const { desc, search, expected } of testData) {
+ info("Running test: " + desc);
+ results = walkerSearch.search(search);
+ isDeeply(
+ results,
+ expected,
+ "Search returns correct results with '" + search + "'"
+ );
+ }
+
+ info("Testing ::before and ::after element matching");
+
+ const beforeElt = new _documentWalker(
+ inspectee.querySelector("#pseudo"),
+ inspectee.defaultView
+ ).firstChild();
+ const afterElt = new _documentWalker(
+ inspectee.querySelector("#pseudo"),
+ inspectee.defaultView
+ ).lastChild();
+ const styleText = inspectee.querySelector("style").childNodes[0];
+
+ // ::before
+ results = walkerSearch.search("::before");
+ isDeeply(
+ results,
+ [{ node: beforeElt, type: "tag" }],
+ "Tag search works for pseudo element"
+ );
+
+ results = walkerSearch.search("_moz_generated_content_before");
+ is(results.length, 0, "No results for anon tag name");
+
+ results = walkerSearch.search("before element");
+ isDeeply(
+ results,
+ [
+ { node: styleText, type: "text" },
+ { node: beforeElt, type: "text" },
+ ],
+ "Text search works for pseudo element"
+ );
+
+ // ::after
+ results = walkerSearch.search("::after");
+ isDeeply(
+ results,
+ [{ node: afterElt, type: "tag" }],
+ "Tag search works for pseudo element"
+ );
+
+ results = walkerSearch.search("_moz_generated_content_after");
+ is(results.length, 0, "No results for anon tag name");
+
+ results = walkerSearch.search("after element");
+ isDeeply(
+ results,
+ [
+ { node: styleText, type: "text" },
+ { node: afterElt, type: "text" },
+ ],
+ "Text search works for pseudo element"
+ );
+
+ info("Testing search before and after a mutation.");
+ const expected = [
+ { node: inspectee.querySelectorAll("h3")[0], type: "tag" },
+ { node: inspectee.querySelectorAll("h3")[1], type: "tag" },
+ { node: inspectee.querySelectorAll("h3")[2], type: "tag" },
+ ];
+
+ results = walkerSearch.search("h3");
+ isDeeply(results, expected, "Search works with tag results");
+
+ function mutateDocumentAndWaitForMutation(mutationFn) {
+ // eslint-disable-next-line new-cap
+ return new Promise(resolve => {
+ info("Listening to markup mutation on the inspectee");
+ const observer = new inspectee.defaultView.MutationObserver(resolve);
+ observer.observe(inspectee, { childList: true, subtree: true });
+ mutationFn();
+ });
+ }
+ await mutateDocumentAndWaitForMutation(() => {
+ expected[0].node.remove();
+ });
+
+ results = walkerSearch.search("h3");
+ isDeeply(
+ results,
+ [expected[1], expected[2]],
+ "Results are updated after removal"
+ );
+
+ // eslint-disable-next-line new-cap
+ await new Promise(resolve => {
+ info("Waiting for a mutation to happen");
+ const observer = new inspectee.defaultView.MutationObserver(() => {
+ resolve();
+ });
+ observer.observe(inspectee, { attributes: true, subtree: true });
+ inspectee.body.setAttribute("h3", "true");
+ });
+
+ results = walkerSearch.search("h3");
+ isDeeply(
+ results,
+ [
+ { node: inspectee.body, type: "attributeName" },
+ expected[1],
+ expected[2],
+ ],
+ "Results are updated after addition"
+ );
+
+ // eslint-disable-next-line new-cap
+ await new Promise(resolve => {
+ info("Waiting for a mutation to happen");
+ const observer = new inspectee.defaultView.MutationObserver(() => {
+ resolve();
+ });
+ observer.observe(inspectee, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ });
+ inspectee.body.removeAttribute("h3");
+ expected[1].node.remove();
+ expected[2].node.remove();
+ });
+
+ results = walkerSearch.search("h3");
+ is(results.length, 0, "Results are updated after removal");
+ }
+ );
+});
diff --git a/devtools/server/tests/browser/browser_inspector-shadow.js b/devtools/server/tests/browser/browser_inspector-shadow.js
new file mode 100644
index 0000000000..7675593c96
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-shadow.js
@@ -0,0 +1,231 @@
+"use strict";
+
+const URL = MAIN_DOMAIN + "inspector-shadow.html";
+
+add_task(async function () {
+ info("Test that a shadow host has a shadow root");
+ const { walker } = await initInspectorFront(URL);
+
+ const el = await walker.querySelector(walker.rootNode, "#empty");
+ const children = await walker.children(el);
+
+ is(el.displayName, "test-empty", "#empty exists");
+ ok(el.isShadowHost, "#empty is a shadow host");
+
+ const shadowRoot = children.nodes[0];
+ ok(shadowRoot.isShadowRoot, "#empty has a shadow-root child");
+ is(children.nodes.length, 1, "#empty has no other children");
+});
+
+add_task(async function () {
+ info("Test that a shadow host has its children too");
+ const { walker } = await initInspectorFront(URL);
+
+ const el = await walker.querySelector(walker.rootNode, "#one-child");
+ const children = await walker.children(el);
+
+ is(
+ children.nodes.length,
+ 2,
+ "#one-child has two children " + "(shadow root + another child)"
+ );
+ ok(children.nodes[0].isShadowRoot, "First child is a shadow-root");
+ is(children.nodes[1].displayName, "h1", "Second child is <h1>");
+});
+
+add_task(async function () {
+ info("Test that shadow-root has its children");
+ const { walker } = await initInspectorFront(URL);
+
+ const el = await walker.querySelector(walker.rootNode, "#shadow-children");
+ ok(el.isShadowHost, "#shadow-children is a shadow host");
+
+ const children = await walker.children(el);
+ ok(
+ children.nodes.length === 1 && children.nodes[0].isShadowRoot,
+ "#shadow-children has only one child and it's a shadow-root"
+ );
+
+ const shadowRoot = children.nodes[0];
+ const shadowChildren = await walker.children(shadowRoot);
+ is(shadowChildren.nodes.length, 2, "shadow-root has two children");
+ is(shadowChildren.nodes[0].displayName, "h1", "First child is <h1>");
+ is(shadowChildren.nodes[1].displayName, "p", "Second child is <p>");
+});
+
+add_task(async function () {
+ info("Test that shadow root has its children and slotted nodes");
+ const { walker } = await initInspectorFront(URL);
+
+ const el = await walker.querySelector(walker.rootNode, "#named-slot");
+ ok(el.isShadowHost, "#named-slot is a shadow host");
+
+ const children = await walker.children(el);
+ is(children.nodes.length, 2, "#named-slot has two children");
+ const shadowRoot = children.nodes[0];
+ ok(shadowRoot.isShadowRoot, "#named-slot has a shadow-root child");
+
+ const slotted = children.nodes[1];
+ is(
+ slotted.getAttribute("slot"),
+ "slot1",
+ "#named-slot as a child that is slotted"
+ );
+
+ const shadowChildren = await walker.children(shadowRoot);
+ is(
+ shadowChildren.nodes[0].displayName,
+ "h1",
+ "shadow-root first child is a regular <h1> tag"
+ );
+ is(
+ shadowChildren.nodes[1].displayName,
+ "slot",
+ "shadow-root second child is a slot"
+ );
+
+ const slottedChildren = await walker.children(shadowChildren.nodes[1]);
+ is(
+ slottedChildren.nodes[0],
+ slotted,
+ "The slot has the slotted node as a child"
+ );
+});
+
+add_task(async function () {
+ info("Test pseudoelements in shadow host");
+ const { walker } = await initInspectorFront(URL);
+
+ const el = await walker.querySelector(walker.rootNode, "#host-pseudo");
+ const children = await walker.children(el);
+
+ ok(children.nodes[0].isShadowRoot, "#host-pseudo 1st child is a shadow root");
+ ok(
+ children.nodes[1].isBeforePseudoElement,
+ "#host-pseudo 2nd child is ::before"
+ );
+ ok(
+ children.nodes[2].isAfterPseudoElement,
+ "#host-pseudo 3rd child is ::after"
+ );
+});
+
+add_task(async function () {
+ info("Test pseudoelements in slotted nodes");
+ const { walker } = await initInspectorFront(URL);
+
+ const el = await walker.querySelector(walker.rootNode, "#slot-pseudo");
+ const shadowRoot = (await walker.children(el)).nodes[0];
+ ok(shadowRoot.isShadowRoot, "#slot-pseudo has a shadow-root child");
+
+ const shadowChildren = await walker.children(shadowRoot);
+ is(shadowChildren.nodes[1].displayName, "slot", "shadow-root has a slot");
+
+ const slottedChildren = await walker.children(shadowChildren.nodes[1]);
+ ok(slottedChildren.nodes[0].isBeforePseudoElement, "slot has ::before");
+ ok(
+ slottedChildren.nodes[slottedChildren.nodes.length - 1]
+ .isAfterPseudoElement,
+ "slot has ::after"
+ );
+});
+
+add_task(async function () {
+ info("Test open/closed modes in shadow roots");
+ const { walker } = await initInspectorFront(URL);
+
+ const openEl = await walker.querySelector(walker.rootNode, "#mode-open");
+ const openShadowRoot = (await walker.children(openEl)).nodes[0];
+ const closedEl = await walker.querySelector(walker.rootNode, "#mode-closed");
+ const closedShadowRoot = (await walker.children(closedEl)).nodes[0];
+
+ is(
+ openShadowRoot.shadowRootMode,
+ "open",
+ "#mode-open has a shadow root with open mode"
+ );
+ is(
+ closedShadowRoot.shadowRootMode,
+ "closed",
+ "#mode-closed has a shadow root with closed mode"
+ );
+});
+
+add_task(async function () {
+ info("Test that slotted inline text nodes appear in the Shadow DOM tree");
+ const { walker } = await initInspectorFront(URL);
+
+ const el = await walker.querySelector(walker.rootNode, "#slot-inline-text");
+ const hostChildren = await walker.children(el);
+ const originalSlot = hostChildren.nodes[1];
+ is(
+ originalSlot.displayName,
+ "#text",
+ "Shadow host as a text node to be slotted"
+ );
+
+ const shadowRoot = hostChildren.nodes[0];
+ const shadowChildren = await walker.children(shadowRoot);
+ const slot = shadowChildren.nodes[0];
+ is(slot.displayName, "slot", "shadow-root has a slot child");
+ ok(!slot._form.inlineTextChild, "Slotted node is not an inline text");
+
+ const slotChildren = await walker.children(slot);
+ const slotted = slotChildren.nodes[0];
+ is(slotted.displayName, "#text", "Slotted node is a text node");
+ is(
+ slotted._form.nodeValue,
+ originalSlot._form.nodeValue,
+ "Slotted content is the same as original's"
+ );
+});
+
+add_task(async function () {
+ info("Test UA widgets when showAllAnonymousContent is true");
+ await SpecialPowers.pushPrefEnv({
+ set: [["devtools.inspector.showAllAnonymousContent", true]],
+ });
+
+ const { walker } = await initInspectorFront(URL);
+
+ let el = await walker.querySelector(walker.rootNode, "#video-controls");
+ let hostChildren = await walker.children(el);
+ is(hostChildren.nodes.length, 3, "#video-controls tag has 3 children");
+ const shadowRoot = hostChildren.nodes[0];
+ ok(shadowRoot.isShadowRoot, "#video-controls has a shadow-root child");
+
+ el = await walker.querySelector(
+ walker.rootNode,
+ "#video-controls-with-children"
+ );
+ hostChildren = await walker.children(el);
+ is(
+ hostChildren.nodes.length,
+ 4,
+ "#video-controls-with-children has 4 children"
+ );
+});
+
+add_task(async function () {
+ info("Test UA widgets when showAllAnonymousContent is false");
+ await SpecialPowers.pushPrefEnv({
+ set: [["devtools.inspector.showAllAnonymousContent", false]],
+ });
+
+ const { walker } = await initInspectorFront(URL);
+
+ let el = await walker.querySelector(walker.rootNode, "#video-controls");
+ let hostChildren = await walker.children(el);
+ is(hostChildren.nodes.length, 0, "#video-controls tag has no children");
+
+ el = await walker.querySelector(
+ walker.rootNode,
+ "#video-controls-with-children"
+ );
+ hostChildren = await walker.children(el);
+ is(
+ hostChildren.nodes.length,
+ 1,
+ "#video-controls-with-children has one child"
+ );
+});
diff --git a/devtools/server/tests/browser/browser_inspector-traversal.js b/devtools/server/tests/browser/browser_inspector-traversal.js
new file mode 100644
index 0000000000..786521cc18
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-traversal.js
@@ -0,0 +1,350 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
+ this
+);
+
+const checkActorIDs = [];
+
+add_task(async function loadNewChild() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ // Make sure that refetching the root document of the walker returns the same
+ // actor as the getWalker returned.
+ const root = await walker.document();
+ Assert.strictEqual(
+ root,
+ walker.rootNode,
+ "Re-fetching the document node should match the root document node."
+ );
+ checkActorIDs.push(root.actorID);
+ await assertOwnershipTrees(walker);
+});
+
+add_task(async function testInnerHTML() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ const docElement = await walker.documentElement();
+ const longstring = await walker.innerHTML(docElement);
+ const innerHTML = await longstring.string();
+ const actualInnerHTML = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ return content.document.documentElement.innerHTML;
+ }
+ );
+ Assert.strictEqual(innerHTML, actualInnerHTML, "innerHTML should match");
+});
+
+add_task(async function testOuterHTML() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ const docElement = await walker.documentElement();
+ const longstring = await walker.outerHTML(docElement);
+ const outerHTML = await longstring.string();
+ const actualOuterHTML = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ return content.document.documentElement.outerHTML;
+ }
+ );
+ Assert.strictEqual(outerHTML, actualOuterHTML, "outerHTML should match");
+});
+
+add_task(async function testSetOuterHTMLNode() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const newHTML = '<p id="edit-html-done">after edit</p>';
+ let node = await walker.querySelector(walker.rootNode, "#edit-html");
+ await walker.setOuterHTML(node, newHTML);
+ node = await walker.querySelector(walker.rootNode, "#edit-html-done");
+ const longstring = await walker.outerHTML(node);
+ const outerHTML = await longstring.string();
+ is(outerHTML, newHTML, "outerHTML has been updated");
+ node = await walker.querySelector(walker.rootNode, "#edit-html");
+ ok(!node, "The node with the old ID cannot be selected anymore");
+});
+
+add_task(async function testQuerySelector() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ let node = await walker.querySelector(walker.rootNode, "#longlist");
+ is(
+ node.getAttribute("data-test"),
+ "exists",
+ "should have found the right node"
+ );
+ await assertOwnershipTrees(walker);
+ node = await walker.querySelector(walker.rootNode, "unknownqueryselector");
+ ok(!node, "Should not find a node here.");
+ await assertOwnershipTrees(walker);
+});
+
+add_task(async function testQuerySelectors() {
+ const { target, walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const nodeList = await walker.querySelectorAll(
+ walker.rootNode,
+ "#longlist div"
+ );
+ is(nodeList.length, 26, "Expect 26 div children.");
+ await assertOwnershipTrees(walker);
+ const firstNode = await nodeList.item(0);
+ checkActorIDs.push(firstNode.actorID);
+ is(firstNode.id, "a", "First child should be a");
+ await assertOwnershipTrees(walker);
+ let nodes = await nodeList.items();
+ is(nodes.length, 26, "Expect 26 nodes");
+ is(nodes[0], firstNode, "First node should be reused.");
+ ok(nodes[0]._parent, "Parent node should be set.");
+ ok(nodes[0]._next || nodes[0]._prev, "Siblings should be set.");
+ ok(
+ nodes[25]._next || nodes[25]._prev,
+ "Siblings of " + nodes[25] + " should be set."
+ );
+ await assertOwnershipTrees(walker);
+ nodes = await nodeList.items(-1);
+ is(nodes.length, 1, "Expect 1 node");
+ is(nodes[0].id, "z", "Expect it to be the last node.");
+ checkActorIDs.push(nodes[0].actorID);
+ // Save the node list ID so we can ensure it was destroyed.
+ const nodeListID = nodeList.actorID;
+ await assertOwnershipTrees(walker);
+ await nodeList.release();
+ ok(!nodeList.actorID, "Actor should have been destroyed.");
+ await assertOwnershipTrees(walker);
+ await checkMissing(target, nodeListID);
+});
+
+// Helper to check the response of requests that return hasFirst/hasLast/nodes
+// node lists (like `children` and `siblings`)
+async function checkArray(walker, children, first, last, ids) {
+ is(
+ children.hasFirst,
+ first,
+ "Should " + (first ? "" : "not ") + " have the first node."
+ );
+ is(
+ children.hasLast,
+ last,
+ "Should " + (last ? "" : "not ") + " have the last node."
+ );
+ is(
+ children.nodes.length,
+ ids.length,
+ "Should have " + ids.length + " children listed."
+ );
+ let responseIds = "";
+ for (const node of children.nodes) {
+ responseIds += node.id;
+ }
+ is(responseIds, ids, "Correct nodes were returned.");
+ await assertOwnershipTrees(walker);
+}
+
+add_task(async function testNoChildren() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const empty = await walker.querySelector(walker.rootNode, "#empty");
+ await assertOwnershipTrees(walker);
+ const children = await walker.children(empty);
+ await checkArray(walker, children, true, true, "");
+});
+
+add_task(async function testLongListTraversal() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const longList = await walker.querySelector(walker.rootNode, "#longlist");
+ // First call with no options, expect all children.
+ await assertOwnershipTrees(walker);
+ let children = await walker.children(longList);
+ await checkArray(walker, children, true, true, "abcdefghijklmnopqrstuvwxyz");
+ const allChildren = children.nodes;
+ await assertOwnershipTrees(walker);
+ // maxNodes should limit us to the first 5 nodes.
+ await assertOwnershipTrees(walker);
+ children = await walker.children(longList, { maxNodes: 5 });
+ await checkArray(walker, children, true, false, "abcde");
+ await assertOwnershipTrees(walker);
+ // maxNodes with the second item centered should still give us the first 5 nodes.
+ children = await walker.children(longList, {
+ maxNodes: 5,
+ center: allChildren[1],
+ });
+ await checkArray(walker, children, true, false, "abcde");
+ // maxNodes with a center in the middle of the list should put that item in the middle
+ const center = allChildren[13];
+ is(center.id, "n", "Make sure I know how to count letters.");
+ children = await walker.children(longList, { maxNodes: 5, center });
+ await checkArray(walker, children, false, false, "lmnop");
+ // maxNodes with the second-to-last item centered should give us the last 5 nodes.
+ children = await walker.children(longList, {
+ maxNodes: 5,
+ center: allChildren[24],
+ });
+ await checkArray(walker, children, false, true, "vwxyz");
+ // maxNodes with a start in the middle should start at that node and fetch 5
+ const start = allChildren[13];
+ is(start.id, "n", "Make sure I know how to count letters.");
+ children = await walker.children(longList, { maxNodes: 5, start });
+ await checkArray(walker, children, false, false, "nopqr");
+ // maxNodes near the end should only return what's left
+ children = await walker.children(longList, {
+ maxNodes: 5,
+ start: allChildren[24],
+ });
+ await checkArray(walker, children, false, true, "yz");
+});
+
+add_task(async function testObjectNodeChildren() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const object = await walker.querySelector(walker.rootNode, "object");
+ const children = await walker.children(object);
+ await checkArray(walker, children, true, true, "1");
+});
+
+add_task(async function testNextSibling() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const y = await walker.querySelector(walker.rootNode, "#y");
+ is(y.id, "y", "Got the right node.");
+ const z = await walker.nextSibling(y);
+ is(z.id, "z", "nextSibling got the next node.");
+ const nothing = await walker.nextSibling(z);
+ is(nothing, null, "nextSibling on the last node returned null.");
+});
+
+add_task(async function testPreviousSibling() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const b = await walker.querySelector(walker.rootNode, "#b");
+ is(b.id, "b", "Got the right node.");
+ const a = await walker.previousSibling(b);
+ is(a.id, "a", "nextSibling got the next node.");
+ const nothing = await walker.previousSibling(a);
+ is(nothing, null, "previousSibling on the first node returned null.");
+});
+
+add_task(async function testFrameTraversal() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const childFrame = await walker.querySelector(walker.rootNode, "#childFrame");
+ const children = await walker.children(childFrame);
+ const nodes = children.nodes;
+ is(nodes.length, 1, "There should be only one child of the iframe");
+ is(
+ nodes[0].nodeType,
+ Node.DOCUMENT_NODE,
+ "iframe child should be a document node"
+ );
+ await walker.querySelector(nodes[0], "#z");
+});
+
+add_task(async function testLongValue() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+
+ SimpleTest.registerCleanupFunction(async function () {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const WalkerActor = require("resource://devtools/server/actors/inspector/walker.js");
+ WalkerActor.setValueSummaryLength(
+ WalkerActor.DEFAULT_VALUE_SUMMARY_LENGTH
+ );
+ });
+ });
+
+ const longstringText = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const testSummaryLength = 10;
+ const WalkerActor = require("resource://devtools/server/actors/inspector/walker.js");
+
+ WalkerActor.setValueSummaryLength(testSummaryLength);
+ return content.document.getElementById("longstring").firstChild.nodeValue;
+ }
+ );
+
+ const node = await walker.querySelector(walker.rootNode, "#longstring");
+ ok(!node.inlineTextChild, "Text is too long to be inlined");
+ // Now we need to get the text node child...
+ const children = await walker.children(node, { maxNodes: 1 });
+ const textNode = children.nodes[0];
+ is(textNode.nodeType, Node.TEXT_NODE, "Value should be a text node");
+ const value = await textNode.getNodeValue();
+ const valueStr = await value.string();
+ is(
+ valueStr,
+ longstringText,
+ "Full node value should match the string from the document."
+ );
+});
+
+add_task(async function testShortValue() {
+ const { walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ const shortstringText = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ return content.document.getElementById("shortstring").firstChild
+ .nodeValue;
+ }
+ );
+
+ const node = await walker.querySelector(walker.rootNode, "#shortstring");
+ ok(!!node.inlineTextChild, "Text is short enough to be inlined");
+ // Now we need to get the text node child...
+ const children = await walker.children(node, { maxNodes: 1 });
+ const textNode = children.nodes[0];
+ is(textNode.nodeType, Node.TEXT_NODE, "Value should be a text node");
+ const value = await textNode.getNodeValue();
+ const valueStr = await value.string();
+ is(
+ valueStr,
+ shortstringText,
+ "Full node value should match the string from the document."
+ );
+});
+
+add_task(async function testReleaseWalker() {
+ const { target, walker } = await initInspectorFront(
+ MAIN_DOMAIN + "inspector-traversal-data.html"
+ );
+ checkActorIDs.push(walker.actorID);
+
+ await walker.release();
+ for (const id of checkActorIDs) {
+ await checkMissing(target, id);
+ }
+});
diff --git a/devtools/server/tests/browser/browser_inspector-utils.js b/devtools/server/tests/browser/browser_inspector-utils.js
new file mode 100644
index 0000000000..b81eeb0178
--- /dev/null
+++ b/devtools/server/tests/browser/browser_inspector-utils.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
+ this
+);
+
+const COLOR_WHITE = [255, 255, 255, 1];
+
+add_task(async function loadNewChild() {
+ const { walker } = await initInspectorFront(
+ `data:text/html,<style>body{color:red;background-color:white;}body::before{content:"test";}</style>`
+ );
+
+ const body = await walker.querySelector(walker.rootNode, "body");
+ const color = await body.getBackgroundColor();
+ Assert.deepEqual(
+ color.value,
+ COLOR_WHITE,
+ "Background color is calculated correctly for an element with a pseudo child."
+ );
+});
diff --git a/devtools/server/tests/browser/browser_layout_getGrids.js b/devtools/server/tests/browser/browser_layout_getGrids.js
new file mode 100644
index 0000000000..ce40cf7a22
--- /dev/null
+++ b/devtools/server/tests/browser/browser_layout_getGrids.js
@@ -0,0 +1,145 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check the output of getGrids for the LayoutActor
+
+const GRID_FRAGMENT_DATA = {
+ areas: [
+ {
+ columnEnd: 3,
+ columnStart: 2,
+ name: "header",
+ rowEnd: 2,
+ rowStart: 1,
+ type: "explicit",
+ },
+ {
+ columnEnd: 2,
+ columnStart: 1,
+ name: "sidebar",
+ rowEnd: 3,
+ rowStart: 2,
+ type: "explicit",
+ },
+ {
+ columnEnd: 3,
+ columnStart: 2,
+ name: "content",
+ rowEnd: 3,
+ rowStart: 2,
+ type: "explicit",
+ },
+ ],
+ cols: {
+ lines: [
+ {
+ breadth: 0,
+ names: ["col-1", "col-start-1", "sidebar-start"],
+ number: 1,
+ start: 0,
+ type: "explicit",
+ },
+ {
+ breadth: 0,
+ names: ["col-2", "header-start", "sidebar-end", "content-start"],
+ number: 2,
+ start: 100,
+ type: "explicit",
+ },
+ {
+ breadth: 0,
+ names: ["header-end", "content-end"],
+ number: 3,
+ start: 200,
+ type: "explicit",
+ },
+ ],
+ tracks: [
+ {
+ breadth: 100,
+ start: 0,
+ state: "static",
+ type: "explicit",
+ },
+ {
+ breadth: 100,
+ start: 100,
+ state: "static",
+ type: "explicit",
+ },
+ ],
+ },
+ rows: {
+ lines: [
+ {
+ breadth: 0,
+ names: ["header-start"],
+ number: 1,
+ start: 0,
+ type: "explicit",
+ },
+ {
+ breadth: 0,
+ names: ["header-end", "sidebar-start", "content-start"],
+ number: 2,
+ start: 100,
+ type: "explicit",
+ },
+ {
+ breadth: 0,
+ names: ["sidebar-end", "content-end"],
+ number: 3,
+ start: 200,
+ type: "explicit",
+ },
+ ],
+ tracks: [
+ {
+ breadth: 100,
+ start: 0,
+ state: "static",
+ type: "explicit",
+ },
+ {
+ breadth: 100,
+ start: 100,
+ state: "static",
+ type: "explicit",
+ },
+ ],
+ },
+};
+
+add_task(async function () {
+ const { target, walker, layout } = await initLayoutFrontForUrl(
+ MAIN_DOMAIN + "grid.html"
+ );
+ const grids = await layout.getGrids(walker.rootNode);
+ const grid = grids[0];
+ const { gridFragments } = grid;
+
+ is(grids.length, 1, "One grid was returned.");
+ is(gridFragments.length, 1, "One grid fragment was returned.");
+ ok(Array.isArray(gridFragments), "An array of grid fragments was returned.");
+ Assert.deepEqual(
+ gridFragments[0],
+ GRID_FRAGMENT_DATA,
+ "Got the correct grid fragment data."
+ );
+
+ info("Get the grid container node front.");
+
+ try {
+ const nodeFront = await walker.getNodeFromActor(grids[0].actorID, [
+ "containerEl",
+ ]);
+ ok(nodeFront, "Got the grid container node front.");
+ } catch (e) {
+ ok(false, "Did not get grid container node front.");
+ }
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_layout_simple.js b/devtools/server/tests/browser/browser_layout_simple.js
new file mode 100644
index 0000000000..d4caba572e
--- /dev/null
+++ b/devtools/server/tests/browser/browser_layout_simple.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Simple checks for the LayoutActor and GridActor
+
+add_task(async function () {
+ const { target, walker, layout } = await initLayoutFrontForUrl(
+ "data:text/html;charset=utf-8,<title>test</title><div></div>"
+ );
+
+ ok(layout, "The LayoutFront was created");
+ ok(layout.getGrids, "The getGrids method exists");
+
+ let didThrow = false;
+ try {
+ await layout.getGrids(null);
+ } catch (e) {
+ didThrow = true;
+ }
+ ok(didThrow, "An exception was thrown for a missing NodeActor in getGrids");
+
+ const invalidNode = await walker.querySelector(walker.rootNode, "title");
+ const grids = await layout.getGrids(invalidNode);
+ ok(Array.isArray(grids), "An array of grids was returned");
+ is(grids.length, 0, "0 grids have been returned for the invalid node");
+
+ await target.destroy();
+ gBrowser.removeCurrentTab();
+});
diff --git a/devtools/server/tests/browser/browser_memory_allocations_01.js b/devtools/server/tests/browser/browser_memory_allocations_01.js
new file mode 100644
index 0000000000..cc6d5b0f58
--- /dev/null
+++ b/devtools/server/tests/browser/browser_memory_allocations_01.js
@@ -0,0 +1,107 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ const target = await addTabTarget("data:text/html;charset=utf-8,test-doc");
+ const memory = await target.getFront("memory");
+
+ await memory.attach();
+
+ await memory.startRecordingAllocations();
+ ok(true, "Can start recording allocations");
+
+ // Allocate some objects.
+ const [line1, line2, line3] = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ function () {
+ // Use eval to ensure allocating the object in the page's compartment
+ return content.eval(
+ "(" +
+ function () {
+ let alloc1, alloc2, alloc3;
+
+ /* eslint-disable max-nested-callbacks */
+ (function outer() {
+ (function middle() {
+ (function inner() {
+ alloc1 = {};
+ alloc1.line = Error().lineNumber;
+ alloc2 = [];
+ alloc2.line = Error().lineNumber;
+ // eslint-disable-next-line new-parens
+ alloc3 = new (function () {})();
+ alloc3.line = Error().lineNumber;
+ })();
+ })();
+ })();
+ /* eslint-enable max-nested-callbacks */
+
+ return [alloc1.line, alloc2.line, alloc3.line];
+ } +
+ ")()"
+ );
+ }
+ );
+
+ const response = await memory.getAllocations();
+
+ await memory.stopRecordingAllocations();
+ ok(true, "Can stop recording allocations");
+
+ // Filter out allocations by library and test code, and get only the
+ // allocations that occurred in our test case above.
+
+ function isTestAllocation(alloc) {
+ const frame = response.frames[alloc];
+ return (
+ frame &&
+ frame.functionDisplayName === "inner" &&
+ (frame.line === line1 || frame.line === line2 || frame.line === line3)
+ );
+ }
+
+ const testAllocations = response.allocations.filter(isTestAllocation);
+ Assert.greaterOrEqual(
+ testAllocations.length,
+ 3,
+ "Should find our 3 test allocations (plus some allocations for the error " +
+ "objects used to get line numbers)"
+ );
+
+ // For each of the test case's allocations, ensure that the parent frame
+ // indices are correct. Also test that we did get an allocation at each
+ // line we expected (rather than a bunch on the first line and none on the
+ // others, etc).
+
+ const expectedLines = new Set([line1, line2, line3]);
+ is(expectedLines.size, 3, "We are expecting 3 allocations");
+
+ for (const alloc of testAllocations) {
+ const innerFrame = response.frames[alloc];
+ ok(innerFrame, "Should get the inner frame");
+ is(innerFrame.functionDisplayName, "inner");
+ expectedLines.delete(innerFrame.line);
+
+ const middleFrame = response.frames[innerFrame.parent];
+ ok(middleFrame, "Should get the middle frame");
+ is(middleFrame.functionDisplayName, "middle");
+
+ const outerFrame = response.frames[middleFrame.parent];
+ ok(outerFrame, "Should get the outer frame");
+ is(outerFrame.functionDisplayName, "outer");
+
+ // Not going to test the rest of the frames because they are Task.jsm
+ // and promise frames and it gets gross. Plus, I wouldn't want this test
+ // to start failing if they changed their implementations in a way that
+ // added or removed stack frames here.
+ }
+
+ is(expectedLines.size, 0, "Should have found all the expected lines");
+
+ await memory.detach();
+
+ await target.destroy();
+});
diff --git a/devtools/server/tests/browser/browser_perf-01.js b/devtools/server/tests/browser/browser_perf-01.js
new file mode 100644
index 0000000000..96afc8151e
--- /dev/null
+++ b/devtools/server/tests/browser/browser_perf-01.js
@@ -0,0 +1,57 @@
+/* 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 test is at the edge of timing out, probably because of LUL
+// initialization on Linux. This is also happening only once, which is why only
+// this test needs it: for other tests LUL is already initialized because
+// they're running in the same Firefox instance.
+// See also bug 1635442.
+requestLongerTimeout(2);
+
+/**
+ * Run through a series of basic recording actions for the perf actor.
+ */
+add_task(async function () {
+ const { front, client } = await initPerfFront();
+
+ // Assert the initial state.
+ is(
+ await front.isSupportedPlatform(),
+ true,
+ "This test only runs on supported platforms."
+ );
+ is(await front.isActive(), false, "The profiler is not active yet.");
+
+ // Start the profiler.
+ const profilerStarted = once(front, "profiler-started");
+ await front.startProfiler();
+ await profilerStarted;
+ is(await front.isActive(), true, "The profiler was started.");
+
+ // Stop the profiler and assert the results.
+ const profilerStopped1 = once(front, "profiler-stopped");
+ const profile = await front.getProfileAndStopProfiler();
+ await profilerStopped1;
+ is(await front.isActive(), false, "The profiler was stopped.");
+ ok("threads" in profile, "The actor was used to record a profile.");
+
+ // Restart the profiler.
+ await front.startProfiler();
+ is(await front.isActive(), true, "The profiler was re-started.");
+
+ // Stop and discard.
+ const profilerStopped2 = once(front, "profiler-stopped");
+ await front.stopProfilerAndDiscardProfile();
+ await profilerStopped2;
+ is(
+ await front.isActive(),
+ false,
+ "The profiler was stopped and the profile discarded."
+ );
+
+ // Clean up.
+ await front.destroy();
+ await client.close();
+});
diff --git a/devtools/server/tests/browser/browser_perf-02.js b/devtools/server/tests/browser/browser_perf-02.js
new file mode 100644
index 0000000000..c7276d8a3f
--- /dev/null
+++ b/devtools/server/tests/browser/browser_perf-02.js
@@ -0,0 +1,37 @@
+/* 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";
+
+/**
+ * Test what happens when other tools control the profiler.
+ */
+add_task(async function () {
+ const { front, client } = await initPerfFront();
+
+ // Simulate other tools by getting an independent handle on the Gecko Profiler.
+ // eslint-disable-next-line mozilla/use-services
+ const geckoProfiler = Cc["@mozilla.org/tools/profiler;1"].getService(
+ Ci.nsIProfiler
+ );
+
+ is(await front.isActive(), false, "The profiler hasn't been started yet.");
+
+ // Start the profiler.
+ await front.startProfiler();
+ is(await front.isActive(), true, "The profiler was started.");
+
+ // Stop the profiler manually through the Gecko Profiler interface.
+ const profilerStopped = once(front, "profiler-stopped");
+ geckoProfiler.StopProfiler();
+ await profilerStopped;
+ is(
+ await front.isActive(),
+ false,
+ "The profiler was stopped by another tool."
+ );
+
+ // Clean up.
+ await front.destroy();
+ await client.close();
+});
diff --git a/devtools/server/tests/browser/browser_perf-04.js b/devtools/server/tests/browser/browser_perf-04.js
new file mode 100644
index 0000000000..9fba77d053
--- /dev/null
+++ b/devtools/server/tests/browser/browser_perf-04.js
@@ -0,0 +1,53 @@
+/* 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";
+
+/**
+ * Run through a series of basic recording actions for the perf actor.
+ */
+add_task(async function () {
+ const { front, client } = await initPerfFront();
+
+ // Assert the initial state.
+ is(
+ await front.isSupportedPlatform(),
+ true,
+ "This test only runs on supported platforms."
+ );
+ is(await front.isActive(), false, "The profiler is not active yet.");
+
+ // Getting the active Browser ID to assert in the "profiler-started" event.
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
+ const activeTabID = win.gBrowser.selectedBrowser.browsingContext.browserId;
+
+ front.once(
+ "profiler-started",
+ (entries, interval, features, duration, activeTID) => {
+ is(entries, 1024, "Should apply entries by startProfiler");
+ is(interval, 0.1, "Should apply interval by startProfiler");
+ is(typeof features, "number", "Should apply features by startProfiler");
+ is(duration, 2, "Should apply duration by startProfiler");
+ is(
+ activeTID,
+ activeTabID,
+ "Should apply active browser ID by startProfiler"
+ );
+ }
+ );
+
+ // Start the profiler.
+ await front.startProfiler({
+ entries: 1000,
+ duration: 2,
+ interval: 0.1,
+ features: ["js", "stackwalk"],
+ });
+
+ is(await front.isActive(), true, "The profiler is active.");
+
+ // clean up
+ await front.stopProfilerAndDiscardProfile();
+ await front.destroy();
+ await client.close();
+});
diff --git a/devtools/server/tests/browser/browser_perf-getSupportedFeatures.js b/devtools/server/tests/browser/browser_perf-getSupportedFeatures.js
new file mode 100644
index 0000000000..331d6d329c
--- /dev/null
+++ b/devtools/server/tests/browser/browser_perf-getSupportedFeatures.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";
+
+add_task(async function () {
+ const { front, client } = await initPerfFront();
+
+ info("Get the supported features from the perf actor.");
+ const features = await front.getSupportedFeatures();
+
+ ok(Array.isArray(features), "The features are an array.");
+ ok(!!features.length, "There are many features supported.");
+ ok(
+ features.includes("js"),
+ "All platforms support the js feature, and it's in this list."
+ );
+
+ // clean up
+ await front.stopProfilerAndDiscardProfile();
+ await front.destroy();
+ await client.close();
+});
diff --git a/devtools/server/tests/browser/browser_storage_cookies-duplicate-names.js b/devtools/server/tests/browser/browser_storage_cookies-duplicate-names.js
new file mode 100644
index 0000000000..0342e1b896
--- /dev/null
+++ b/devtools/server/tests/browser/browser_storage_cookies-duplicate-names.js
@@ -0,0 +1,134 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the storage panel is able to display multiple cookies with the same
+// name (and different paths).
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js",
+ this
+);
+
+const l10n = new Localization(["devtools/client/storage.ftl"], true);
+const sessionString = l10n.formatValueSync("storage-expires-session");
+
+const TESTDATA = {
+ "http://test1.example.org": [
+ {
+ name: "name",
+ value: "value1",
+ expires: 0,
+ path: "/",
+ host: "test1.example.org",
+ hostOnly: true,
+ isSecure: false,
+ },
+ {
+ name: "name",
+ value: "value2",
+ expires: 0,
+ path: "/path2/",
+ host: "test1.example.org",
+ hostOnly: true,
+ isSecure: false,
+ },
+ {
+ name: "name",
+ value: "value3",
+ expires: 0,
+ path: "/path3/",
+ host: "test1.example.org",
+ hostOnly: true,
+ isSecure: false,
+ },
+ ],
+};
+
+add_task(async function () {
+ const { commands } = await openTabAndSetupStorage(
+ MAIN_DOMAIN + "storage-cookies-same-name.html"
+ );
+
+ const { resourceCommand } = commands;
+ const { TYPES } = resourceCommand;
+ const data = {};
+ await resourceCommand.watchResources(
+ [TYPES.COOKIE, TYPES.LOCAL_STORAGE, TYPES.SESSION_STORAGE],
+ {
+ async onAvailable(resources) {
+ for (const resource of resources) {
+ const { resourceType } = resource;
+ if (!data[resourceType]) {
+ data[resourceType] = { hosts: {}, dataByHost: {} };
+ }
+
+ for (const host in resource.hosts) {
+ if (!data[resourceType].hosts[host]) {
+ data[resourceType].hosts[host] = [];
+ }
+ // For indexed DB, we have some values, the database names. Other are empty arrays.
+ const hostValues = resource.hosts[host];
+ data[resourceType].hosts[host].push(...hostValues);
+ data[resourceType].dataByHost[host] =
+ await resource.getStoreObjects(host, null, { sessionString });
+ }
+ }
+ },
+ }
+ );
+
+ ok(data.cookies, "Cookies storage actor is present");
+
+ await testCookies(data.cookies);
+ await clearStorage();
+
+ // Forcing GC/CC to get rid of docshells and windows created by this test.
+ forceCollections();
+ await commands.destroy();
+ forceCollections();
+});
+
+function testCookies({ hosts, dataByHost }) {
+ const numHosts = Object.keys(hosts).length;
+ is(numHosts, 1, "Correct number of host entries for cookies");
+ return testCookiesObjects(0, hosts, dataByHost);
+}
+
+var testCookiesObjects = async function (index, hosts, dataByHost) {
+ const host = Object.keys(hosts)[index];
+ const data = dataByHost[host];
+ is(
+ data.total,
+ TESTDATA[host].length,
+ "Number of cookies in host " + host + " matches"
+ );
+ for (const item of data.data) {
+ let found = false;
+ for (const toMatch of TESTDATA[host]) {
+ if (
+ item.name === toMatch.name &&
+ item.host === toMatch.host &&
+ item.path === toMatch.path
+ ) {
+ found = true;
+ ok(true, "Found cookie " + item.name + " in response");
+ is(item.value.str, toMatch.value, "The value matches.");
+ is(item.expires, toMatch.expires, "The expiry time matches.");
+ is(item.path, toMatch.path, "The path matches.");
+ is(item.host, toMatch.host, "The host matches.");
+ is(item.isSecure, toMatch.isSecure, "The isSecure value matches.");
+ is(item.hostOnly, toMatch.hostOnly, "The hostOnly value matches.");
+ break;
+ }
+ }
+ ok(found, "cookie " + item.name + " should exist in response");
+ }
+
+ ok(!!TESTDATA[host], "Host is present in the list : " + host);
+ if (index == Object.keys(hosts).length - 1) {
+ return;
+ }
+ await testCookiesObjects(++index, hosts, dataByHost);
+};
diff --git a/devtools/server/tests/browser/browser_storage_dynamic_windows.js b/devtools/server/tests/browser/browser_storage_dynamic_windows.js
new file mode 100644
index 0000000000..0417cf0f09
--- /dev/null
+++ b/devtools/server/tests/browser/browser_storage_dynamic_windows.js
@@ -0,0 +1,410 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js",
+ this
+);
+
+// beforeReload references an object representing the initialized state of the
+// storage actor.
+const beforeReload = {
+ cookies: {
+ "http://test1.example.org": ["c1", "cs2", "c3", "uc1"],
+ "http://sectest1.example.org": ["uc1", "cs2"],
+ },
+ "indexed-db": {
+ "http://test1.example.org": [
+ JSON.stringify(["idb1", "obj1"]),
+ JSON.stringify(["idb1", "obj2"]),
+ JSON.stringify(["idb2", "obj3"]),
+ ],
+ "http://sectest1.example.org": [],
+ },
+ "local-storage": {
+ "http://test1.example.org": ["ls1", "ls2"],
+ "http://sectest1.example.org": ["iframe-u-ls1"],
+ },
+ "session-storage": {
+ "http://test1.example.org": ["ss1"],
+ "http://sectest1.example.org": ["iframe-u-ss1", "iframe-u-ss2"],
+ },
+};
+
+// afterIframeAdded references the items added when an iframe containing storage
+// items is added to the page.
+const afterIframeAdded = {
+ cookies: {
+ "https://sectest1.example.org": [
+ getCookieId("cs2", ".example.org", "/"),
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/server/tests/browser"
+ ),
+ ],
+ "http://sectest1.example.org": [
+ getCookieId(
+ "sc1",
+ "sectest1.example.org",
+ "/browser/devtools/server/tests/browser"
+ ),
+ ],
+ },
+ "indexed-db": {
+ // empty because indexed db creation happens after the page load, so at
+ // the time of window-ready, there was no indexed db present.
+ "https://sectest1.example.org": [],
+ },
+ "local-storage": {
+ "https://sectest1.example.org": ["iframe-s-ls1"],
+ },
+ "session-storage": {
+ "https://sectest1.example.org": ["iframe-s-ss1"],
+ },
+};
+
+// afterIframeRemoved references the items deleted when an iframe containing
+// storage items is removed from the page.
+const afterIframeRemoved = {
+ cookies: {
+ "http://sectest1.example.org": [],
+ },
+ "indexed-db": {
+ "http://sectest1.example.org": [],
+ },
+ "local-storage": {
+ "http://sectest1.example.org": [],
+ },
+ "session-storage": {
+ "http://sectest1.example.org": [],
+ },
+};
+
+add_task(async function () {
+ const { commands } = await openTabAndSetupStorage(
+ MAIN_DOMAIN + "storage-dynamic-windows.html"
+ );
+
+ const { resourceCommand } = commands;
+ const { TYPES } = resourceCommand;
+ const allResources = {};
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ is(
+ resource.targetFront.targetType,
+ commands.targetCommand.TYPES.FRAME,
+ "Each storage resource has a valid 'targetFront' attribute"
+ );
+ // Because we have iframes, we have distinct targets, each spawning their own storage resource
+ if (allResources[resource.resourceType]) {
+ allResources[resource.resourceType].push(resource);
+ } else {
+ allResources[resource.resourceType] = [resource];
+ }
+ }
+ };
+ const parentProcessStorages = [TYPES.COOKIE, TYPES.INDEXED_DB];
+ const contentProcessStorages = [TYPES.LOCAL_STORAGE, TYPES.SESSION_STORAGE];
+ const allStorages = [...parentProcessStorages, ...contentProcessStorages];
+ await resourceCommand.watchResources(allStorages, { onAvailable });
+ is(
+ Object.keys(allStorages).length,
+ allStorages.length,
+ "Got all the storage resources"
+ );
+
+ // Do a copy of all the initial storages as test function may spawn new resources for the same
+ // type and override the initial ones.
+ // We do not call unwatchResources as it would clear its cache and next call
+ // to watchResources with ignoreExistingResources would break and reprocess all resources again.
+ const initialResources = Object.assign({}, allResources);
+
+ testWindowsBeforeReload(initialResources);
+
+ await testAddIframe(commands, initialResources, {
+ contentProcessStorages,
+ parentProcessStorages,
+ allStorages,
+ });
+
+ await testRemoveIframe(commands, initialResources, {
+ contentProcessStorages,
+ parentProcessStorages,
+ allStorages,
+ });
+
+ await clearStorage();
+
+ // Forcing GC/CC to get rid of docshells and windows created by this test.
+ forceCollections();
+ await commands.destroy();
+ forceCollections();
+});
+
+function testWindowsBeforeReload(resources) {
+ for (const storageType in beforeReload) {
+ ok(resources[storageType], `${storageType} storage actor is present`);
+
+ const hosts = {};
+ for (const resource of resources[storageType]) {
+ for (const [hostType, hostValues] of Object.entries(resource.hosts)) {
+ if (!hosts[hostType]) {
+ hosts[hostType] = [];
+ }
+
+ hosts[hostType].push(hostValues);
+ }
+ }
+
+ // If this test is run with chrome debugging enabled we get an extra
+ // key for "chrome". We don't want the test to fail in this case, so
+ // ignore it.
+ if (storageType == "indexedDB") {
+ delete hosts.chrome;
+ }
+
+ is(
+ Object.keys(hosts).length,
+ Object.keys(beforeReload[storageType]).length,
+ `Number of hosts for ${storageType} match`
+ );
+ for (const host in beforeReload[storageType]) {
+ ok(hosts[host], `Host ${host} is present`);
+ }
+ }
+}
+
+/**
+ * Wait for new storage resources to be created of the given types.
+ */
+async function waitForNewResourcesAndUpdates(commands, resourceTypes) {
+ // When fission is off, we don't expect any new resource
+ if (resourceTypes.length === 0) {
+ return { newResources: [], updates: [] };
+ }
+ const { resourceCommand } = commands;
+ let resolve;
+ const promise = new Promise(r => (resolve = r));
+ const allResources = {};
+ const allUpdates = {};
+ const onAvailable = resources => {
+ for (const resource of resources) {
+ if (resource.resourceType in allResources) {
+ ok(false, `Got multiple ${resource.resourceTypes} resources`);
+ }
+ allResources[resource.resourceType] = resource;
+ ok(true, `Got resource for ${resource.resourceType}`);
+
+ // Stop watching for resources when we got them all
+ if (Object.keys(allResources).length == resourceTypes.length) {
+ resourceCommand.unwatchResources(resourceTypes, {
+ onAvailable,
+ });
+ }
+
+ // But also listen for updates on each new resource
+ resource.once("single-store-update").then(update => {
+ ok(true, `Got updates for ${resource.resourceType}`);
+ allUpdates[resource.resourceType] = update;
+
+ // Resolve only once we got all the updates, for all the resources
+ if (Object.keys(allUpdates).length == resourceTypes.length) {
+ resolve({ newResources: allResources, updates: allUpdates });
+ }
+ });
+ }
+ };
+ await resourceCommand.watchResources(resourceTypes, {
+ onAvailable,
+ ignoreExistingResources: true,
+ });
+ return promise;
+}
+
+/**
+ * Wait for single-store-update events on all the given storage resources.
+ */
+function waitForResourceUpdates(resources, resourceTypes) {
+ const allUpdates = {};
+ const promises = [];
+ for (const type of resourceTypes) {
+ // Resolves once any of the many resources for the given storage type updates
+ const promise = Promise.any(
+ resources[type].map(resource => resource.once("single-store-update"))
+ );
+ promise.then(update => {
+ ok(true, `Got updates for ${type}`);
+ allUpdates[type] = update;
+ });
+ promises.push(promise);
+ }
+ return Promise.all(promises).then(() => allUpdates);
+}
+
+async function testAddIframe(
+ commands,
+ resources,
+ { contentProcessStorages, parentProcessStorages, allStorages }
+) {
+ info("Testing if new iframe addition works properly");
+
+ // If Fission or EFT is enabled:
+ // * we get new resources alongside single-store-update events for content process storages
+ // * only single-store-update events for previous resources for parent process storages
+ // Otherwise if fission is disables:
+ // * we get single-store-update events for all previous resources
+ const onResources = waitForNewResourcesAndUpdates(
+ commands,
+ isFissionEnabled() || isEveryFrameTargetEnabled()
+ ? contentProcessStorages
+ : []
+ );
+ // If fission or EFT is enabled, we only get update for parent process storages.
+ // The content process storage resources are notified via brand new resource instances.
+ const storagesWithUpdates =
+ isFissionEnabled() || isEveryFrameTargetEnabled()
+ ? parentProcessStorages
+ : allStorages;
+ const onUpdates = waitForResourceUpdates(resources, storagesWithUpdates);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [ALT_DOMAIN_SECURED],
+ secured => {
+ const doc = content.document;
+
+ const iframe = doc.createElement("iframe");
+ iframe.src = secured + "storage-secured-iframe.html";
+
+ doc.querySelector("body").appendChild(iframe);
+ }
+ );
+
+ info("Wait for all resources");
+ const { newResources, updates } = await onResources;
+ info("Wait for all updates");
+ const previousResourceUpdates = await onUpdates;
+
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ for (const resourceType of contentProcessStorages) {
+ const resource = newResources[resourceType];
+ const expected = afterIframeAdded[resourceType];
+ // The resource only comes with hosts, without any values.
+ // Each host will be an empty array.
+ Assert.deepEqual(
+ Object.keys(resource.hosts),
+ Object.keys(expected),
+ `List of hosts for resource ${resourceType} is correct`
+ );
+ for (const host in resource.hosts) {
+ is(
+ resource.hosts[host].length,
+ 0,
+ "For new resources, each host has no value and is an empty array"
+ );
+ }
+ const update = updates[resourceType];
+ const storageKey = resourceTypeToStorageKey(resourceType);
+ Assert.deepEqual(
+ update.added[storageKey],
+ expected,
+ "We get an update after the resource, with the host values"
+ );
+ }
+ }
+
+ for (const resourceType of storagesWithUpdates) {
+ const expected = afterIframeAdded[resourceType];
+ const update = previousResourceUpdates[resourceType];
+ const storageKey = resourceTypeToStorageKey(resourceType);
+ Assert.deepEqual(
+ update.added[storageKey],
+ expected,
+ `We get an update after the resource ${resourceType}, with the host values`
+ );
+ }
+
+ return newResources;
+}
+
+async function testRemoveIframe(
+ commands,
+ resources,
+ { contentProcessStorages, parentProcessStorages, allStorages }
+) {
+ info("Testing if iframe removal works properly");
+
+ // If fission or EFT is enabled, we only get update for parent process storages.
+ // The content process storage resources are wiped via their related target destruction.
+ const onUpdates = waitForResourceUpdates(
+ resources,
+ isFissionEnabled() || isEveryFrameTargetEnabled()
+ ? parentProcessStorages
+ : allStorages
+ );
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ for (const iframe of content.document.querySelectorAll("iframe")) {
+ if (iframe.src.startsWith("http:")) {
+ iframe.remove();
+ break;
+ }
+ }
+ });
+
+ info("Wait for all updates");
+ const previousResourceUpdates = await onUpdates;
+
+ const storagesWithUpdates =
+ isFissionEnabled() || isEveryFrameTargetEnabled()
+ ? parentProcessStorages
+ : allStorages;
+ for (const resourceType of storagesWithUpdates) {
+ const expected = afterIframeRemoved[resourceType];
+ const update = previousResourceUpdates[resourceType];
+ const storageKey = resourceTypeToStorageKey(resourceType);
+ Assert.deepEqual(
+ update.deleted[storageKey],
+ expected,
+ `We get an update after the resource ${resourceType}, with the host values`
+ );
+ }
+
+ // With Fission or EFT, the iframe target is destroyed,
+ // which ends up destroying the related resources
+ if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
+ const destroyedResourceTypes = [];
+ for (const storageType in resources) {
+ for (const resource of resources[storageType]) {
+ if (resource.isDestroyed()) {
+ destroyedResourceTypes.push(resource.resourceType);
+ }
+ }
+ }
+ Assert.deepEqual(
+ destroyedResourceTypes.sort(),
+ contentProcessStorages.sort(),
+ "Content process storage resources have been destroyed [local and session storages]"
+ );
+ }
+}
+
+/**
+ * single-store-update emits objects using attributes with old "storage key" namings,
+ * which is different from resource type namings.
+ */
+function resourceTypeToStorageKey(resourceType) {
+ if (resourceType == "local-storage") {
+ return "localStorage";
+ }
+ if (resourceType == "session-storage") {
+ return "sessionStorage";
+ }
+ if (resourceType == "indexed-db") {
+ return "indexedDB";
+ }
+ return resourceType;
+}
diff --git a/devtools/server/tests/browser/browser_storage_listings.js b/devtools/server/tests/browser/browser_storage_listings.js
new file mode 100644
index 0000000000..40365ede85
--- /dev/null
+++ b/devtools/server/tests/browser/browser_storage_listings.js
@@ -0,0 +1,743 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js",
+ this
+);
+
+const l10n = new Localization(["devtools/client/storage.ftl"], true);
+const sessionString = l10n.formatValueSync("storage-expires-session");
+
+const storeMap = {
+ cookies: {
+ "http://test1.example.org": [
+ {
+ name: "c1",
+ value: "foobar",
+ expires: 2000000000000,
+ path: "/browser",
+ host: "test1.example.org",
+ hostOnly: true,
+ isSecure: false,
+ },
+ {
+ name: "cs2",
+ value: "sessionCookie",
+ path: "/",
+ host: ".example.org",
+ expires: 0,
+ hostOnly: false,
+ isSecure: false,
+ },
+ {
+ name: "c3",
+ value: "foobar-2",
+ expires: 2000000001000,
+ path: "/",
+ host: "test1.example.org",
+ hostOnly: true,
+ isSecure: true,
+ },
+ ],
+
+ "http://sectest1.example.org": [
+ {
+ name: "cs2",
+ value: "sessionCookie",
+ path: "/",
+ host: ".example.org",
+ expires: 0,
+ hostOnly: false,
+ isSecure: false,
+ },
+ {
+ name: "sc1",
+ value: "foobar",
+ path: "/browser/devtools/server/tests/browser",
+ host: "sectest1.example.org",
+ expires: 0,
+ hostOnly: true,
+ isSecure: false,
+ },
+ ],
+
+ "https://sectest1.example.org": [
+ {
+ name: "uc1",
+ value: "foobar",
+ host: ".example.org",
+ path: "/",
+ expires: 0,
+ hostOnly: false,
+ isSecure: true,
+ },
+ {
+ name: "cs2",
+ value: "sessionCookie",
+ path: "/",
+ host: ".example.org",
+ expires: 0,
+ hostOnly: false,
+ isSecure: false,
+ },
+ {
+ name: "sc1",
+ value: "foobar",
+ path: "/browser/devtools/server/tests/browser",
+ host: "sectest1.example.org",
+ expires: 0,
+ hostOnly: true,
+ isSecure: false,
+ },
+ ],
+ },
+ "local-storage": {
+ "http://test1.example.org": [
+ {
+ name: "ls1",
+ value: "foobar",
+ },
+ {
+ name: "ls2",
+ value: "foobar-2",
+ },
+ ],
+ "http://sectest1.example.org": [
+ {
+ name: "iframe-u-ls1",
+ value: "foobar",
+ },
+ ],
+ "https://sectest1.example.org": [
+ {
+ name: "iframe-s-ls1",
+ value: "foobar",
+ },
+ ],
+ },
+ "session-storage": {
+ "http://test1.example.org": [
+ {
+ name: "ss1",
+ value: "foobar-3",
+ },
+ ],
+ "http://sectest1.example.org": [
+ {
+ name: "iframe-u-ss1",
+ value: "foobar1",
+ },
+ {
+ name: "iframe-u-ss2",
+ value: "foobar2",
+ },
+ ],
+ "https://sectest1.example.org": [
+ {
+ name: "iframe-s-ss1",
+ value: "foobar-2",
+ },
+ ],
+ },
+};
+
+const IDBValues = {
+ listStoresResponse: {
+ "http://test1.example.org": [
+ ["idb1 (default)", "obj1"],
+ ["idb1 (default)", "obj2"],
+ ["idb2 (default)", "obj3"],
+ ],
+ "http://sectest1.example.org": [],
+ "https://sectest1.example.org": [
+ ["idb-s1 (default)", "obj-s1"],
+ ["idb-s2 (default)", "obj-s2"],
+ ],
+ },
+ dbDetails: {
+ "http://test1.example.org": [
+ {
+ db: "idb1 (default)",
+ origin: "http://test1.example.org",
+ version: 1,
+ objectStores: 2,
+ },
+ {
+ db: "idb2 (default)",
+ origin: "http://test1.example.org",
+ version: 1,
+ objectStores: 1,
+ },
+ ],
+ "http://sectest1.example.org": [],
+ "https://sectest1.example.org": [
+ {
+ db: "idb-s1 (default)",
+ origin: "https://sectest1.example.org",
+ version: 1,
+ objectStores: 1,
+ },
+ {
+ db: "idb-s2 (default)",
+ origin: "https://sectest1.example.org",
+ version: 1,
+ objectStores: 1,
+ },
+ ],
+ },
+ objectStoreDetails: {
+ "http://test1.example.org": {
+ "idb1 (default)": [
+ {
+ objectStore: "obj1",
+ keyPath: "id",
+ autoIncrement: false,
+ indexes: [
+ {
+ name: "name",
+ keyPath: "name",
+ unique: false,
+ multiEntry: false,
+ },
+ {
+ name: "email",
+ keyPath: "email",
+ unique: true,
+ multiEntry: false,
+ },
+ ],
+ },
+ {
+ objectStore: "obj2",
+ keyPath: "id2",
+ autoIncrement: false,
+ indexes: [],
+ },
+ ],
+ "idb2 (default)": [
+ {
+ objectStore: "obj3",
+ keyPath: "id3",
+ autoIncrement: false,
+ indexes: [
+ {
+ name: "name2",
+ keyPath: "name2",
+ unique: true,
+ multiEntry: false,
+ },
+ ],
+ },
+ ],
+ },
+ "http://sectest1.example.org": {},
+ "https://sectest1.example.org": {
+ "idb-s1 (default)": [
+ {
+ objectStore: "obj-s1",
+ keyPath: "id",
+ autoIncrement: false,
+ indexes: [],
+ },
+ ],
+ "idb-s2 (default)": [
+ {
+ objectStore: "obj-s2",
+ keyPath: "id3",
+ autoIncrement: true,
+ indexes: [
+ {
+ name: "name2",
+ keyPath: "name2",
+ unique: true,
+ multiEntry: false,
+ },
+ ],
+ },
+ ],
+ },
+ },
+ entries: {
+ "http://test1.example.org": {
+ "idb1 (default)#obj1": [
+ {
+ name: 1,
+ value: {
+ id: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ },
+ },
+ {
+ name: 2,
+ value: {
+ id: 2,
+ name: "foo2",
+ email: "foo2@bar.com",
+ },
+ },
+ {
+ name: 3,
+ value: {
+ id: 3,
+ name: "foo2",
+ email: "foo3@bar.com",
+ },
+ },
+ ],
+ "idb1 (default)#obj2": [
+ {
+ name: 1,
+ value: {
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz",
+ },
+ },
+ ],
+ "idb2 (default)#obj3": [],
+ },
+ "http://sectest1.example.org": {},
+ "https://sectest1.example.org": {
+ "idb-s1 (default)#obj-s1": [
+ {
+ name: 6,
+ value: {
+ id: 6,
+ name: "foo",
+ email: "foo@bar.com",
+ },
+ },
+ {
+ name: 7,
+ value: {
+ id: 7,
+ name: "foo2",
+ email: "foo2@bar.com",
+ },
+ },
+ ],
+ "idb-s2 (default)#obj-s2": [
+ {
+ name: 13,
+ value: {
+ id2: 13,
+ name2: "foo",
+ email: "foo@bar.com",
+ },
+ },
+ ],
+ },
+ },
+};
+
+async function testStores(commands) {
+ const { resourceCommand } = commands;
+ const { TYPES } = resourceCommand;
+ /**
+ * Data is a dictionary whose keys are storage types (their resourceType)
+ * while values are objects with following attributes:
+ * - hosts: dictionary of storage values (values are specific to each storage type)
+ * keyed by host names.
+ * - dataByHost: dictionary of storage objects keyed by host names.
+ * storages objects are returned by StorageActor.getStoreObjects.
+ * For IndexedDB it is different, instead it is still a dictionary
+ * keyed by host names, but each value is yet another sub dictionary with
+ * a special "main" attribute, with global store objects.
+ * Then, there will be one key per idb database, with their store objects
+ * as value.
+ */
+ const data = {};
+ await resourceCommand.watchResources(
+ [
+ TYPES.COOKIE,
+ TYPES.LOCAL_STORAGE,
+ TYPES.SESSION_STORAGE,
+ TYPES.INDEXED_DB,
+ ],
+ {
+ async onAvailable(resources) {
+ for (const resource of resources) {
+ const { resourceType } = resource;
+ if (!data[resourceType]) {
+ data[resourceType] = { hosts: {}, dataByHost: {} };
+ }
+
+ for (const host in resource.hosts) {
+ if (!data[resourceType].hosts[host]) {
+ data[resourceType].hosts[host] = [];
+ }
+ // For indexed DB, we have some values, the database names. Other are empty arrays.
+ const hostValues = resource.hosts[host];
+ data[resourceType].hosts[host].push(...hostValues);
+
+ // For INDEXED_DB, it is slightly more complex, as we may have 3 store per host,
+ if (resourceType == TYPES.INDEXED_DB) {
+ if (!data[resourceType].dataByHost[host]) {
+ data[resourceType].dataByHost[host] = {};
+ }
+ data[resourceType].dataByHost[host].main =
+ await resource.getStoreObjects(host, null, {
+ sessionString,
+ });
+ for (const name of resource.hosts[host]) {
+ const objName = JSON.parse(name).slice(0, 1);
+ data[resourceType].dataByHost[host][objName] =
+ await resource.getStoreObjects(
+ host,
+ [JSON.stringify(objName)],
+ { sessionString }
+ );
+ data[resourceType].dataByHost[host][name] =
+ await resource.getStoreObjects(host, [name], {
+ sessionString,
+ });
+ }
+ } else {
+ data[resourceType].dataByHost[host] =
+ await resource.getStoreObjects(host, null, { sessionString });
+ }
+ }
+ }
+ },
+ }
+ );
+
+ await testCookies(data.cookies);
+ await testLocalStorage(data["local-storage"]);
+ await testSessionStorage(data["session-storage"]);
+ await testIndexedDB(data["indexed-db"]);
+}
+
+function testCookies({ hosts, dataByHost }) {
+ is(
+ Object.keys(hosts).length,
+ 3,
+ "Correct number of host entries for cookies"
+ );
+ return testCookiesObjects(0, hosts, dataByHost);
+}
+
+async function testCookiesObjects(index, hosts, dataByHost) {
+ const host = Object.keys(hosts)[index];
+ ok(!!storeMap.cookies[host], "Host is present in the list : " + host);
+ const data = dataByHost[host];
+ let cookiesLength = 0;
+ for (const secureCookie of storeMap.cookies[host]) {
+ if (secureCookie.isSecure) {
+ ++cookiesLength;
+ }
+ }
+ // Any secure cookies did not get stored in the database.
+ is(
+ data.total,
+ storeMap.cookies[host].length - cookiesLength,
+ "Number of cookies in host " + host + " matches"
+ );
+ for (const item of data.data) {
+ let found = false;
+ for (const toMatch of storeMap.cookies[host]) {
+ if (item.name == toMatch.name) {
+ found = true;
+ ok(true, "Found cookie " + item.name + " in response");
+ is(item.value.str, toMatch.value, "The value matches.");
+ is(item.expires, toMatch.expires, "The expiry time matches.");
+ is(item.path, toMatch.path, "The path matches.");
+ is(item.host, toMatch.host, "The host matches.");
+ is(item.isSecure, toMatch.isSecure, "The isSecure value matches.");
+ is(item.hostOnly, toMatch.hostOnly, "The hostOnly value matches.");
+ break;
+ }
+ }
+ ok(found, "cookie " + item.name + " should exist in response");
+ }
+
+ if (index == Object.keys(hosts).length - 1) {
+ return;
+ }
+ await testCookiesObjects(++index, hosts, dataByHost);
+}
+
+function testLocalStorage({ hosts, dataByHost }) {
+ is(
+ Object.keys(hosts).length,
+ 3,
+ "Correct number of host entries for local storage"
+ );
+ return testLocalStorageObjects(0, hosts, dataByHost);
+}
+
+var testLocalStorageObjects = async function (index, hosts, dataByHost) {
+ const host = Object.keys(hosts)[index];
+ ok(
+ !!storeMap["local-storage"][host],
+ "Host is present in the list : " + host
+ );
+ const data = dataByHost[host];
+ is(
+ data.total,
+ storeMap["local-storage"][host].length,
+ "Number of local storage items in host " + host + " matches"
+ );
+ for (const item of data.data) {
+ let found = false;
+ for (const toMatch of storeMap["local-storage"][host]) {
+ if (item.name == toMatch.name) {
+ found = true;
+ ok(true, "Found local storage item " + item.name + " in response");
+ is(item.value.str, toMatch.value, "The value matches.");
+ break;
+ }
+ }
+ ok(found, "local storage item " + item.name + " should exist in response");
+ }
+
+ if (index == Object.keys(hosts).length - 1) {
+ return;
+ }
+ await testLocalStorageObjects(++index, hosts, dataByHost);
+};
+
+function testSessionStorage({ hosts, dataByHost }) {
+ is(
+ Object.keys(hosts).length,
+ 3,
+ "Correct number of host entries for session storage"
+ );
+ return testSessionStorageObjects(0, hosts, dataByHost);
+}
+
+async function testSessionStorageObjects(index, hosts, dataByHost) {
+ const host = Object.keys(hosts)[index];
+ ok(
+ !!storeMap["session-storage"][host],
+ "Host is present in the list : " + host
+ );
+ const data = dataByHost[host];
+ is(
+ data.total,
+ storeMap["session-storage"][host].length,
+ "Number of session storage items in host " + host + " matches"
+ );
+ for (const item of data.data) {
+ let found = false;
+ for (const toMatch of storeMap["session-storage"][host]) {
+ if (item.name == toMatch.name) {
+ found = true;
+ ok(true, "Found session storage item " + item.name + " in response");
+ is(item.value.str, toMatch.value, "The value matches.");
+ break;
+ }
+ }
+ ok(
+ found,
+ "session storage item " + item.name + " should exist in response"
+ );
+ }
+
+ if (index == Object.keys(hosts).length - 1) {
+ return;
+ }
+ await testSessionStorageObjects(++index, hosts, dataByHost);
+}
+
+async function testIndexedDB({ hosts, dataByHost }) {
+ is(
+ Object.keys(hosts).length,
+ 3,
+ "Correct number of host entries for indexed db"
+ );
+
+ for (const host in hosts) {
+ for (const item of hosts[host]) {
+ const parsedItem = JSON.parse(item);
+ let found = false;
+ for (const toMatch of IDBValues.listStoresResponse[host]) {
+ if (toMatch[0] == parsedItem[0] && toMatch[1] == parsedItem[1]) {
+ found = true;
+ break;
+ }
+ }
+ ok(found, item + " should exist in list stores response");
+ }
+ }
+
+ await testIndexedDBs(0, hosts, dataByHost);
+ await testObjectStores(0, hosts, dataByHost);
+ await testIDBEntries(0, hosts, dataByHost);
+}
+
+async function testIndexedDBs(index, hosts, dataByHost) {
+ const host = Object.keys(hosts)[index];
+ const data = dataByHost[host].main;
+ is(
+ data.total,
+ IDBValues.dbDetails[host].length,
+ "Number of indexed db in host " + host + " matches"
+ );
+ for (const item of data.data) {
+ let found = false;
+ for (const toMatch of IDBValues.dbDetails[host]) {
+ if (item.uniqueKey == toMatch.db) {
+ found = true;
+ ok(true, "Found indexed db " + item.uniqueKey + " in response");
+ is(item.origin, toMatch.origin, "The origin matches.");
+ is(item.version, toMatch.version, "The version matches.");
+ is(
+ item.objectStores,
+ toMatch.objectStores,
+ "The number of object stores matches."
+ );
+ break;
+ }
+ }
+ ok(found, "indexed db " + item.uniqueKey + " should exist in response");
+ }
+
+ ok(!!IDBValues.dbDetails[host], "Host is present in the list : " + host);
+ if (index == Object.keys(hosts).length - 1) {
+ return;
+ }
+ await testIndexedDBs(++index, hosts, dataByHost);
+}
+
+async function testObjectStores(ix, hosts, dataByHost) {
+ const host = Object.keys(hosts)[ix];
+ const matchItems = (data, db) => {
+ is(
+ data.total,
+ IDBValues.objectStoreDetails[host][db].length,
+ "Number of object stores in host " + host + " matches"
+ );
+ for (const item of data.data) {
+ let found = false;
+ for (const toMatch of IDBValues.objectStoreDetails[host][db]) {
+ if (item.objectStore == toMatch.objectStore) {
+ found = true;
+ ok(true, "Found object store " + item.objectStore + " in response");
+ is(item.keyPath, toMatch.keyPath, "The keyPath matches.");
+ is(
+ item.autoIncrement,
+ toMatch.autoIncrement,
+ "The autoIncrement matches."
+ );
+ // We might already have parsed the JSON value, in which case this will no longer be a string
+ item.indexes =
+ typeof item.indexes == "string"
+ ? JSON.parse(item.indexes)
+ : item.indexes;
+ is(
+ item.indexes.length,
+ toMatch.indexes.length,
+ "Number of indexes match"
+ );
+ for (const index of item.indexes) {
+ let indexFound = false;
+ for (const toMatchIndex of toMatch.indexes) {
+ if (toMatchIndex.name == index.name) {
+ indexFound = true;
+ ok(true, "Found index " + index.name);
+ is(
+ index.keyPath,
+ toMatchIndex.keyPath,
+ "The keyPath of index matches."
+ );
+ is(index.unique, toMatchIndex.unique, "The unique matches");
+ is(
+ index.multiEntry,
+ toMatchIndex.multiEntry,
+ "The multiEntry matches"
+ );
+ break;
+ }
+ }
+ ok(indexFound, "Index " + index + " should exist in response");
+ }
+ break;
+ }
+ }
+ ok(found, "indexed db " + item.name + " should exist in response");
+ }
+ };
+
+ ok(
+ !!IDBValues.objectStoreDetails[host],
+ "Host is present in the list : " + host
+ );
+ for (const name of hosts[host]) {
+ const objName = JSON.parse(name).slice(0, 1);
+ matchItems(dataByHost[host][objName], objName[0]);
+ }
+ if (ix == Object.keys(hosts).length - 1) {
+ return;
+ }
+ await testObjectStores(++ix, hosts, dataByHost);
+}
+
+async function testIDBEntries(index, hosts, dataByHost) {
+ const host = Object.keys(hosts)[index];
+ const matchItems = (data, obj) => {
+ is(
+ data.total,
+ IDBValues.entries[host][obj].length,
+ "Number of items in object store " + obj + " matches"
+ );
+ for (const item of data.data) {
+ let found = false;
+ for (const toMatch of IDBValues.entries[host][obj]) {
+ if (item.name == toMatch.name) {
+ found = true;
+ ok(true, "Found indexed db item " + item.name + " in response");
+ const value = JSON.parse(item.value.str);
+ is(
+ Object.keys(value).length,
+ Object.keys(toMatch.value).length,
+ "Number of entries in the value matches"
+ );
+ for (const key in value) {
+ is(
+ value[key],
+ toMatch.value[key],
+ "value of " + key + " value key matches"
+ );
+ }
+ break;
+ }
+ }
+ ok(found, "indexed db item " + item.name + " should exist in response");
+ }
+ };
+
+ ok(!!IDBValues.entries[host], "Host is present in the list : " + host);
+ for (const name of hosts[host]) {
+ const parsed = JSON.parse(name);
+ matchItems(dataByHost[host][name], parsed[0] + "#" + parsed[1]);
+ }
+ if (index == Object.keys(hosts).length - 1) {
+ return;
+ }
+ await testObjectStores(++index, hosts, dataByHost);
+}
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.documentCookies.maxage", 0]],
+ });
+
+ const { commands } = await openTabAndSetupStorage(
+ MAIN_DOMAIN + "storage-listings.html"
+ );
+
+ await testStores(commands);
+
+ await clearStorage();
+
+ // Forcing GC/CC to get rid of docshells and windows created by this test.
+ forceCollections();
+ await commands.destroy();
+ forceCollections();
+});
diff --git a/devtools/server/tests/browser/browser_storage_updates.js b/devtools/server/tests/browser/browser_storage_updates.js
new file mode 100644
index 0000000000..50926538a5
--- /dev/null
+++ b/devtools/server/tests/browser/browser_storage_updates.js
@@ -0,0 +1,343 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Ensure that storage updates are detected and that the correct information is
+// contained inside the storage actors.
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js",
+ this
+);
+
+const l10n = new Localization(["devtools/client/storage.ftl"], true);
+const sessionString = l10n.formatValueSync("storage-expires-session");
+
+const TESTS = [
+ // index 0
+ {
+ async action(win) {
+ await addCookie("c1", "foobar1");
+ await addCookie("c2", "foobar2");
+ await localStorageSetItem("l1", "foobar1");
+ },
+ snapshot: {
+ cookies: [
+ {
+ name: "c1",
+ value: "foobar1",
+ },
+ {
+ name: "c2",
+ value: "foobar2",
+ },
+ ],
+ "local-storage": [
+ {
+ name: "l1",
+ value: "foobar1",
+ },
+ ],
+ },
+ },
+
+ // index 1
+ {
+ async action() {
+ await addCookie("c1", "new_foobar1");
+ await localStorageSetItem("l2", "foobar2");
+ },
+ snapshot: {
+ cookies: [
+ {
+ name: "c1",
+ value: "new_foobar1",
+ },
+ {
+ name: "c2",
+ value: "foobar2",
+ },
+ ],
+ "local-storage": [
+ {
+ name: "l1",
+ value: "foobar1",
+ },
+ {
+ name: "l2",
+ value: "foobar2",
+ },
+ ],
+ },
+ },
+
+ // index 2
+ {
+ async action() {
+ await removeCookie("c2");
+ await localStorageRemoveItem("l1");
+ await localStorageSetItem("l3", "foobar3");
+ },
+ snapshot: {
+ cookies: [
+ {
+ name: "c1",
+ value: "new_foobar1",
+ },
+ ],
+ "local-storage": [
+ {
+ name: "l2",
+ value: "foobar2",
+ },
+ {
+ name: "l3",
+ value: "foobar3",
+ },
+ ],
+ },
+ },
+
+ // index 3
+ {
+ async action() {
+ await removeCookie("c1");
+ await addCookie("c3", "foobar3");
+ await localStorageRemoveItem("l2");
+ await sessionStorageSetItem("s1", "foobar1");
+ await sessionStorageSetItem("s2", "foobar2");
+ await localStorageSetItem("l3", "new_foobar3");
+ },
+ snapshot: {
+ cookies: [
+ {
+ name: "c3",
+ value: "foobar3",
+ },
+ ],
+ "local-storage": [
+ {
+ name: "l3",
+ value: "new_foobar3",
+ },
+ ],
+ "session-storage": [
+ {
+ name: "s1",
+ value: "foobar1",
+ },
+ {
+ name: "s2",
+ value: "foobar2",
+ },
+ ],
+ },
+ },
+
+ // index 4
+ {
+ async action() {
+ await sessionStorageRemoveItem("s1");
+ },
+ snapshot: {
+ cookies: [
+ {
+ name: "c3",
+ value: "foobar3",
+ },
+ ],
+ "local-storage": [
+ {
+ name: "l3",
+ value: "new_foobar3",
+ },
+ ],
+ "session-storage": [
+ {
+ name: "s2",
+ value: "foobar2",
+ },
+ ],
+ },
+ },
+
+ // index 5
+ {
+ async action() {
+ await clearCookies();
+ },
+ snapshot: {
+ cookies: [],
+ "local-storage": [
+ {
+ name: "l3",
+ value: "new_foobar3",
+ },
+ ],
+ "session-storage": [
+ {
+ name: "s2",
+ value: "foobar2",
+ },
+ ],
+ },
+ },
+
+ // index 6
+ {
+ async action() {
+ await clearLocalAndSessionStores();
+ },
+ snapshot: {
+ cookies: [],
+ "local-storage": [],
+ "session-storage": [],
+ },
+ },
+];
+
+add_task(async function () {
+ const { commands } = await openTabAndSetupStorage(
+ MAIN_DOMAIN + "storage-updates.html"
+ );
+
+ for (let i = 0; i < TESTS.length; i++) {
+ const test = TESTS[i];
+ await runTest(test, commands, i);
+ }
+
+ await commands.destroy();
+});
+
+async function runTest({ action, snapshot }, commands, index) {
+ info("Running test at index " + index);
+ await action();
+ await checkStores(commands, snapshot);
+}
+
+async function checkStores(commands, snapshot) {
+ const { resourceCommand } = commands;
+ const { TYPES } = resourceCommand;
+ const actual = {};
+ await resourceCommand.watchResources(
+ [TYPES.COOKIE, TYPES.LOCAL_STORAGE, TYPES.SESSION_STORAGE],
+ {
+ async onAvailable(resources) {
+ for (const resource of resources) {
+ actual[resource.resourceType] = await resource.getStoreObjects(
+ TEST_DOMAIN,
+ null,
+ {
+ sessionString,
+ }
+ );
+ }
+ },
+ }
+ );
+
+ for (const [type, entries] of Object.entries(snapshot)) {
+ const store = actual[type].data;
+
+ is(
+ store.length,
+ entries.length,
+ `The number of entries in ${type} is correct`
+ );
+
+ for (const entry of entries) {
+ checkStoreValue(entry.name, entry.value, store);
+ }
+ }
+}
+
+function checkStoreValue(name, value, store) {
+ for (const entry of store) {
+ if (entry.name === name) {
+ ok(true, `There is an entry for "${name}"`);
+
+ // entry.value is a longStringActor so we need to read it's value using
+ // entry.value.str.
+ is(entry.value.str, value, `Value for ${name} is correct`);
+ return;
+ }
+ }
+ ok(false, `There is an entry for "${name}"`);
+}
+
+async function addCookie(name, value) {
+ info(`addCookie("${name}", "${value}")`);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[name, value]],
+ ([iName, iValue]) => {
+ content.wrappedJSObject.window.addCookie(iName, iValue);
+ }
+ );
+}
+
+async function removeCookie(name) {
+ info(`removeCookie("${name}")`);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], iName => {
+ content.wrappedJSObject.window.removeCookie(iName);
+ });
+}
+
+async function localStorageSetItem(name, value) {
+ info(`localStorageSetItem("${name}", "${value}")`);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[name, value]],
+ ([iName, iValue]) => {
+ content.window.localStorage.setItem(iName, iValue);
+ }
+ );
+}
+
+async function localStorageRemoveItem(name) {
+ info(`localStorageRemoveItem("${name}")`);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], iName => {
+ content.window.localStorage.removeItem(iName);
+ });
+}
+
+async function sessionStorageSetItem(name, value) {
+ info(`sessionStorageSetItem("${name}", "${value}")`);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[name, value]],
+ ([iName, iValue]) => {
+ content.window.sessionStorage.setItem(iName, iValue);
+ }
+ );
+}
+
+async function sessionStorageRemoveItem(name) {
+ info(`sessionStorageRemoveItem("${name}")`);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], iName => {
+ content.window.sessionStorage.removeItem(iName);
+ });
+}
+
+async function clearCookies() {
+ info(`clearCookies()`);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.wrappedJSObject.window.clearCookies();
+ });
+}
+
+async function clearLocalAndSessionStores() {
+ info(`clearLocalAndSessionStores()`);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.wrappedJSObject.window.clearLocalAndSessionStores();
+ });
+}
diff --git a/devtools/server/tests/browser/browser_style_utils_getFontPreviewData.js b/devtools/server/tests/browser/browser_style_utils_getFontPreviewData.js
new file mode 100644
index 0000000000..7145e90446
--- /dev/null
+++ b/devtools/server/tests/browser/browser_style_utils_getFontPreviewData.js
@@ -0,0 +1,137 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that getFontPreviewData of the style utils generates font previews.
+
+const TEST_URI = "data:text/html,<title>Test getFontPreviewData</title>";
+
+add_task(async function () {
+ await addTab(TEST_URI);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ getFontPreviewData,
+ } = require("resource://devtools/server/actors/utils/style-utils.js");
+
+ const font = Services.appinfo.OS === "WINNT" ? "Arial" : "Liberation Sans";
+ let fontPreviewData = getFontPreviewData(font, content.document);
+ ok(
+ fontPreviewData?.dataURL,
+ "Returned a font preview with a valid dataURL"
+ );
+
+ // Create <img> element and load the generated preview into it
+ // to check whether the image is valid and get its dimensions
+ const image = content.document.createElement("img");
+ let imageLoaded = new Promise(loaded =>
+ image.addEventListener("load", loaded, { once: true })
+ );
+ image.src = fontPreviewData.dataURL;
+ await imageLoaded;
+
+ const { naturalWidth: widthImage1, naturalHeight: heightImage1 } = image;
+
+ Assert.greater(widthImage1, 0, "Preview width is greater than 0");
+ Assert.greater(heightImage1, 0, "Preview height is greater than 0");
+
+ // Create a preview with different text and compare
+ // its dimensions with the first one
+ fontPreviewData = getFontPreviewData(font, content.document, {
+ previewText: "Abcdef",
+ });
+
+ ok(
+ fontPreviewData?.dataURL,
+ "Returned a font preview with a valid dataURL"
+ );
+
+ imageLoaded = new Promise(loaded =>
+ image.addEventListener("load", loaded, { once: true })
+ );
+ image.src = fontPreviewData.dataURL;
+ await imageLoaded;
+
+ const { naturalWidth: widthImage2, naturalHeight: heightImage2 } = image;
+
+ // Check whether the width is greater than with the default parameters
+ // and that the height is the same
+ Assert.greater(
+ widthImage2,
+ widthImage1,
+ "Preview width is greater than with default parameters"
+ );
+ Assert.strictEqual(
+ heightImage2,
+ heightImage1,
+ "Preview height is the same as with default parameters"
+ );
+
+ // Create a preview with smaller font size and compare
+ // its dimensions with the first one
+ fontPreviewData = getFontPreviewData(font, content.document, {
+ previewFontSize: 20,
+ });
+
+ ok(
+ fontPreviewData?.dataURL,
+ "Returned a font preview with a valid dataURL"
+ );
+
+ imageLoaded = new Promise(loaded =>
+ image.addEventListener("load", loaded, { once: true })
+ );
+ image.src = fontPreviewData.dataURL;
+ await imageLoaded;
+
+ const { naturalWidth: widthImage3, naturalHeight: heightImage3 } = image;
+
+ // Check whether the width and height are smaller than with the default parameters
+ Assert.less(
+ widthImage3,
+ widthImage1,
+ "Preview width is smaller than with default parameters"
+ );
+ Assert.less(
+ heightImage3,
+ heightImage1,
+ "Preview height is smaller than with default parameters"
+ );
+
+ // Create a preview with multiple lines and compare
+ // its dimensions with the first one
+ fontPreviewData = getFontPreviewData(font, content.document, {
+ previewText: "Abc\ndef",
+ });
+
+ ok(
+ fontPreviewData?.dataURL,
+ "Returned a font preview with a valid dataURL"
+ );
+
+ imageLoaded = new Promise(loaded =>
+ image.addEventListener("load", loaded, { once: true })
+ );
+ image.src = fontPreviewData.dataURL;
+ await imageLoaded;
+
+ const { naturalWidth: widthImage4, naturalHeight: heightImage4 } = image;
+
+ // Check whether the width is the same as with the default parameters
+ // and that the height is greater
+ Assert.strictEqual(
+ widthImage4,
+ widthImage1,
+ "Preview width is the same as with default parameters"
+ );
+ Assert.greater(
+ heightImage4,
+ heightImage1,
+ "Preview height is greater than with default parameters"
+ );
+ });
+});
diff --git a/devtools/server/tests/browser/browser_styles_getRuleText.js b/devtools/server/tests/browser/browser_styles_getRuleText.js
new file mode 100644
index 0000000000..e775bcbb28
--- /dev/null
+++ b/devtools/server/tests/browser/browser_styles_getRuleText.js
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that StyleRuleActor.getRuleText returns the contents of the CSS rule.
+
+const CSS_RULE = `#test {
+ background-color: #f06;
+}`;
+
+const CONTENT = `
+ <style type='text/css'>
+ ${CSS_RULE}
+ </style>
+ <div id="test"></div>
+`;
+
+const TEST_URI = `data:text/html;charset=utf-8,${encodeURIComponent(CONTENT)}`;
+
+add_task(async function () {
+ const { inspector, target, walker } = await initInspectorFront(TEST_URI);
+
+ const pageStyle = await inspector.getPageStyle();
+ const element = await walker.querySelector(walker.rootNode, "#test");
+ const entries = await pageStyle.getApplied(element, { inherited: false });
+
+ const rule = entries[1].rule;
+ const text = await rule.getRuleText();
+
+ is(text, CSS_RULE, "CSS rule text content matches");
+
+ await target.destroy();
+});
diff --git a/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js b/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js
new file mode 100644
index 0000000000..a8c069e950
--- /dev/null
+++ b/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that StyleSheetsActor.getText handles empty text correctly.
+
+const CSS_CONTENT = "body { background-color: #f06; }";
+const TEST_URI = `data:text/html;charset=utf-8,<style>${encodeURIComponent(
+ CSS_CONTENT
+)}</style>`;
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+ const target = commands.targetCommand.targetFront;
+
+ const styleSheetsFront = await target.getFront("stylesheets");
+ ok(styleSheetsFront, "The StyleSheetsFront was created.");
+
+ const sheets = [];
+ await commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.STYLESHEET],
+ {
+ onAvailable: resources => sheets.push(...resources),
+ }
+ );
+ is(sheets.length, 1, "watchResources returned the correct number of sheets");
+
+ const { resourceId } = sheets[0];
+
+ is(
+ await getStyleSheetText(styleSheetsFront, resourceId),
+ CSS_CONTENT,
+ "The stylesheet has expected initial text"
+ );
+ info("Update stylesheet content via the styleSheetsFront");
+ await styleSheetsFront.update(resourceId, "", false);
+ is(
+ await getStyleSheetText(styleSheetsFront, resourceId),
+ "",
+ "Stylesheet is now empty, as expected"
+ );
+
+ await commands.destroy();
+});
+
+async function getStyleSheetText(styleSheetsFront, resourceId) {
+ const longStringFront = await styleSheetsFront.getText(resourceId);
+ return longStringFront.string();
+}
diff --git a/devtools/server/tests/browser/director-script-target.html b/devtools/server/tests/browser/director-script-target.html
new file mode 100644
index 0000000000..c436a5446c
--- /dev/null
+++ b/devtools/server/tests/browser/director-script-target.html
@@ -0,0 +1,18 @@
+<html>
+ <head>
+ <script>
+ /* exported globalAccessibleVar */
+ "use strict";
+ // change the eval function to ensure the window object
+ // in the debug-script is correctly wrapped
+ // eslint-disable-next-line no-eval
+ window.eval = function() {
+ return "unsecure-eval-called";
+ };
+ var globalAccessibleVar = "global-value";
+ </script>
+ </head>
+ <body>
+ <h1>debug script target</h1>
+ </body>
+</html>
diff --git a/devtools/server/tests/browser/doc_accessibility.html b/devtools/server/tests/browser/doc_accessibility.html
new file mode 100644
index 0000000000..845dd7c562
--- /dev/null
+++ b/devtools/server/tests/browser/doc_accessibility.html
@@ -0,0 +1,19 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+<body>
+ <h1 id="h1">Accessibility Test</h1>
+ <button id="button" aria-describedby="h1" accesskey="b">Accessible Button</button>
+ <div id="slider" role="slider" aria-valuenow="5"
+ aria-valuemin="0" aria-valuemax="7">slider</div>
+ <label id="label" for="control">Label
+ <input id="control" aria-details="details">
+ </label>
+ <div id="details">details</div>
+ <header id="header">header</header>
+ <nav id="nav">nav</nav>
+ <footer id="footer">footer</footer>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/doc_accessibility_audit.html b/devtools/server/tests/browser/doc_accessibility_audit.html
new file mode 100644
index 0000000000..0667e0569e
--- /dev/null
+++ b/devtools/server/tests/browser/doc_accessibility_audit.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+<body style="color: red;">
+ <p id="p1">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
+ <p id="p2">Accessible Paragraph</p>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/doc_accessibility_infobar.html b/devtools/server/tests/browser/doc_accessibility_infobar.html
new file mode 100644
index 0000000000..8f3c66911c
--- /dev/null
+++ b/devtools/server/tests/browser/doc_accessibility_infobar.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+<body>
+ <h1 id="h1">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</h1>
+ <button id="button">Accessible Button</button>
+ <p id="p" style="font-size: 0;">This is a paragraph that has no bounds.</p>
+ <label>Enter text: <input id="input" type="text"></text></label>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/doc_accessibility_keyboard_audit.html b/devtools/server/tests/browser/doc_accessibility_keyboard_audit.html
new file mode 100644
index 0000000000..00c002efe9
--- /dev/null
+++ b/devtools/server/tests/browser/doc_accessibility_keyboard_audit.html
@@ -0,0 +1,150 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <style>
+ #focusable-1 {
+ outline: none;
+ }
+
+ #focusable-2:focus {
+ outline: none;
+ border: 1px solid black;
+ }
+ </style>
+ </head>
+<body>
+ <div id="button-1" class="Button" tabindex="0">I should really be a button</div>
+ <div id="button-2" class="Button">I should really be a button</div>
+ <div id="input-container"><input id="input-1" type="text" tabindex="-1" /></div>
+ <input id="input-2" type="text" tabindex="-1" disabled />
+ <input id="input-3" type="text" disabled />
+ <input id="input-4" type="text" />
+ <a id="link-1">Though a link, I'm not interactive.</a>
+ <a id="link-2" href="example.com">I'm a proper link.</a>
+ <a id="link-3" href="#">Link 3</a>
+ <a id="link-4" href="">Link 4</a>
+ <a id="link-5" href="https://example.com">Website</a>
+ <button id="button-3">Button with no tabindex</button>
+ <button id="button-4" tabindex="-1">Button with -1 tabindex</button>
+ <button id="button-5" tabindex="0">Button with 0 tabindex</button>
+ <button id="button-6" tabindex="1">Button with 1 tabindex</button>
+ <div id="focusable-1" role="button" tabindex="0">Focusable with no focus style.</div>
+ <div id="focusable-2" role="button" tabindex="0">Focusable with focus style.</div>
+ <div id="focusable-3" role="button" tabindex="0">Focusable with focus style.</div>
+ <div id="mouse-only-1" onclick="console.log('foo');">Button for mouse only</div>
+ <div id="focusable-4" onclick="console.log('foo');" tabindex="0">Button no semantics</div>
+ <div id="button-7" onclick="console.log('foo');" role="button">Semantic button not focusable</div>
+ <div id="button-8" onclick="console.log('foo');" role="button" tabindex="0">Button</div>
+ <img id="img-1" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <img id="img-2" longdesc="https://example.com" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <img id="img-3" longdesc="https://example.com" onclick="console.log('foo');" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <img id="img-4" onclick="console.log('foo');" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <button id="buttonmenu-1" aria-haspopup="true">I have a popup</button>
+ <div role="button" id="buttonmenu-2" aria-haspopup="true">I have a popup</div>
+ <input id="checkbox-1" type="checkbox" name="hello" />
+ <select id="listbox-1" size="2">
+ <option id="lb_orange">orange</option>
+ <option id="lb_apple">apple</option>
+ </select>
+ <select id="combobox-1"></select>
+ <select id="combobox-2"><option>One</option></select>
+ <select id="combobox-3">
+ <option id="cb_orange">orange</option>
+ <option id="cb_apple">apple</option>
+ </select>
+ <div id="editcombobox-1" role="combobox"><span role="option">One</span></div>
+ <span id="editcombobox-2"role="combobox"></span>
+ <span id="editcombobox-3"role="combobox" tabindex="0"></span>
+ <span id="switch-1" role="switch"></span>
+ <span id="switch-2" role="switch" tabindex="0"></span>
+ <div aria-label="Tag" role="combobox" aria-expanded="true" aria-owns="owned_listbox" aria-haspopup="listbox">
+ <input type="text" aria-autocomplete="list" aria-controls="owned_listbox" aria-activedescendant="selected_option">
+ </div>
+ <ul role="listbox" id="owned_listbox">
+ <li role="option">Zebra</li>
+ <li role="option" id="selected_option">Zoom</li>
+ </ul>
+ <label id="label-1">hello<input type="checkbox" name="world" /></label>
+ <label id="label-2" for="checkbox-1">hello</label>
+ <label id="label-3">hello</label>
+ <label id="label-4">hello</label><input type="checkbox" name="world" />
+ <a href="about:mozilla" target="_blank" rel="opener">
+ <img id="img-5" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ </a>
+ <a onmousedown="">
+ <img id="img-6" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ </a>
+ <a onclick="">
+ <img id="img-7" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ </a>
+ <a onmouseup="">
+ <img id="img-8" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ </a>
+ <section id="section-1" class="collapsible-section top-sites animation-enabled" aria-expanded="true"></section>
+ <main id="main" tabindex="-1">Main content</main>
+ <div id="not-keyboard-focusable-1" tabindex="-1">Not keyboard fqocusable with no focus style.</div>
+ <div id="grid-1" role="grid" aria-label="Interactive grid"></div>
+ <div id="grid-2" tabindex="0" role="grid" aria-label="Interactive grid"></div>
+ <div id="table-1" role="table" aria-label="Non-interactive ARIA table"></div>
+ <div id="table-2" tabindex="0" role="table" aria-label="Non-interactive ARIA table"></div>
+ <table id="table-3" aria-label="Non-interactive table"></table>
+ <table id="table-4" tabindex="0" aria-label="Non-interactive table"></table>
+ <div id="article-1" role="article"></div>
+ <div id="article-2" role="article" tabindex="0"></div>
+ <div role="grid" aria-label="Interactive grid">
+ <div id="columnheader-1" role="columnheader"></div>
+ <div id="rowheader-1" role="rowheader"></div>
+ <div id="gridcell-1" role="gridcell"></div>
+ <div id="gridcell-2" role="gridcell" tabindex="0"></div>
+ </div>
+ <div role="table" aria-label="Non-interactive table">
+ <div id="columnheader-2" role="columnheader"></div>
+ <div id="rowheader-2" role="rowheader"></div>
+ </div>
+ <table>
+ <tr>
+ <th id="columnheader-3">Animals</th>
+ </tr>
+ <tr>
+ <th id="columnheader-4" tabindex="0">Hippopotamus</th>
+ </tr>
+ <tr>
+ <th id="rowheader-3">Horse</th>
+ <td>Mare</td>
+ </tr>
+ <tr>
+ <th id="rowheader-4" tabindex="0">Chicken</th>
+ <td>Hen</td>
+ </tr>
+ </table>
+ <table role="grid">
+ <tr>
+ <th id="columnheader-5">Animals</th>
+ </tr>
+ <tr>
+ <th id="columnheader-6" tabindex="0">Hippopotamus</th>
+ </tr>
+ <tr>
+ <th id="rowheader-5">Horse</th>
+ <td id="gridcell-3">Mare</td>
+ </tr>
+ <tr>
+ <th id="rowheader-6" tabindex="0">Chicken</th>
+ <td id="gridcell-4" tabindex="0">Hen</td>
+ </tr>
+ </table>
+ <div id="tablist-1" role="tablist"></div>
+ <div id="tablist-2" role="tablist" tabindex="0"></div>
+ <div id="scrollbar-1" role="scrollbar"></div>
+ <div id="scrollbar-2" role="scrollbar" tabindex="0"></div>
+ <div id="separator-1" role="separator"></div>
+ <div id="separator-2" role="separator" tabindex="0"></div>
+ <div id="toolbar-1" role="toolbar"></div>
+ <div id="toolbar-2" role="toolbar" tabindex="0"></div>
+ <div id="menu-1" role="menu"></div>
+ <div id="menu-2" role="menu" tabindex="0"></div>
+ <div id="menubar-1" role="menubar"></div>
+ <div id="menubar-2" role="menubar" tabindex="0"></div>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/doc_accessibility_text_label_audit.html b/devtools/server/tests/browser/doc_accessibility_text_label_audit.html
new file mode 100644
index 0000000000..982cc5c243
--- /dev/null
+++ b/devtools/server/tests/browser/doc_accessibility_text_label_audit.html
@@ -0,0 +1,463 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+<body>
+ <button id="buttonmenu-1" aria-haspopup="true">I have a popup</button>
+ <label>I have a popup<button id="buttonmenu-2" aria-haspopup="true"></button></label>
+ <button id="buttonmenu-3" aria-haspopup="true"></button>
+ <button id="buttonmenu-4" aria-haspopup="true" aria-label="I have a popup"></button>
+ <label for="buttonmenu-5">I have a popup </label><button id="buttonmenu-5" aria-haspopup="true"></button>
+ <label id="buttonmenu-6-label">I have a popup </label><button id="buttonmenu-6" aria-haspopup="true" aria-labelledby="buttonmenu-6-label"></button>
+ <p id="p1">I am a paragraph</p>
+ <p id="p2"></p>
+ <canvas id="canvas-1"></canvas>
+ <canvas id="canvas-2" aria-label="Canvas label"></canvas>
+ <canvas id="canvas-3" aria-labelledby="canvas-3-heading">
+ <h2 id="canvas-3-heading">Shapes</h2>
+ </canvas>
+ <canvas id="canvas-4">
+ <h2>Shapes</h2>
+ </canvas>
+ <input id="checkbox-1" type="checkbox" name="world" />
+ <label>hello</label><input id="checkbox-2" type="checkbox" name="world" />
+ <label>hello<input id="checkbox-3" type="checkbox" name="world" /></label>
+ <label for="checkbox-4">hello</label><input id="checkbox-4" type="checkbox" name="world" />
+ <input id="checkbox-5" type="checkbox" name="world" aria-label="hello" />
+ <label id="checkbox-6-label">hello</label><input id="checkbox-6" type="checkbox" name="world" aria-labelledby="checkbox-6-label" />
+ <div id="checkbox-7" role="checkbox"></div>
+ <div id="checkbox-8" aria-label="hello" role="checkbox"></div>
+ <div id="checkbox-9-label">hello</div><div id="checkbox-9" aria-labelledby="checkbox-9-label" role="checkbox"></div>
+ <div role="menu">
+ <div id="menuitemcheckbox-1" role="menuitemcheckbox">hello</div>
+ <div id="menuitemcheckbox-2" role="menuitemcheckbox"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" /></div>
+ <div id="menuitemcheckbox-3" role="menuitemcheckbox"></div>
+ <div id="menuitemcheckbox-4" role="menuitemcheckbox"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="" /></div>
+ <div id="menuitemcheckbox-5" role="menuitemcheckbox"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="hello" /></div>
+ <div id="menuitemcheckbox-6" role="menuitemcheckbox">&nbsp;</div>
+ </div>
+ <p id="columnheader-7-label">Budget</p>
+ <p id="rowheader-7-label">Toy Story 3</p>
+ <table>
+ <thead>
+ <tr>
+ <th id="columnheader-1" scope="col">Film Title</th>
+ <th id="columnheader-2" scope="col"></th>
+ <th id="columnheader-3" scope="col">&nbsp;</th>
+ <th id="columnheader-4" scope="col" aria-label="Worldwide Gross"></th>
+ <th id="columnheader-5" scope="col" aria-label=""></th>
+ <th id="columnheader-6" scope="col" aria-label=" "></th>
+ <th id="columnheader-7" scope="col" aria-labelledby="columnheader-7-label"></th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr><th id="rowheader-1" scope="row">Toy Story 3</th></tr>
+ <tr><th id="rowheader-2" scope="row"></th></tr>
+ <tr><th id="rowheader-3" scope="row">&nbsp;</th></tr>
+ <tr><th id="rowheader-4" scope="row" aria-label="Alladin"></th></tr>
+ <tr><th id="rowheader-5" scope="row" aria-label=""></th></tr>
+ <tr><th id="rowheader-6" scope="row" aria-label=" "></th></tr>
+ <tr><th id="rowheader-7" scope="row" aria-labelledby="columnheader-7-label"></th></tr>
+ </tbody>
+ </table>
+ <div role="columnheader" id="columnheader-8">Film Title</div>
+ <div role="columnheader" id="columnheader-9"></div>
+ <div role="columnheader" id="columnheader-10">&nbsp;</div>
+ <div role="columnheader" id="columnheader-11" aria-label="Worldwide Gross"></div>
+ <div role="columnheader" id="columnheader-12" aria-label=""></div>
+ <div role="columnheader" id="columnheader-13" aria-label=" "></div>
+ <div role="columnheader" id="columnheader-14" aria-labelledby="columnheader-7-label"></div>
+ <label for="combobox-1">Choose a pet:</label>
+ <select id="combobox-1">
+ <option id="combobox-option-1" value="">--Please choose an option--</option>
+ <option id="combobox-option-2" value="dog"></option>
+ <option id="combobox-option-3" value="cat">&nbsp;</option>
+ <option id="combobox-option-4" value="" label="--Please choose an option--"></option>
+ <option id="combobox-option-5" value="dog" label=""></option>
+ <option id="combobox-option-6" value="cat" label=" "></option>
+ </select>
+ <select id="combobox-2"></select>
+ <label>Choose a pet:</label><select id="combobox-3"></select>
+ <label>Choose a pet:<select id="combobox-4"></select></label>
+ <select id="combobox-5" aria-label="Choose a pet:"></select>
+ <label id="combobox-6-label">Choose a pet:</label><select id="combobox-6" aria-labelledby="combobox-6-label"></select>
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-1"
+ xmlns:xlink="http://www.w3.org/1999/xlink"></svg>
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-2" aria-label=""
+ xmlns:xlink="http://www.w3.org/1999/xlink"></svg>
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-3" aria-label="empty drawing"
+ xmlns:xlink="http://www.w3.org/1999/xlink"></svg>
+ <div id="diagram-4-label">Empty drawing</div>
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-4" aria-labelledby="diagram-4-label"
+ xmlns:xlink="http://www.w3.org/1999/xlink"></svg>
+ <div id="diagram-5-label"></div>
+ <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-5" aria-labelledby="diagram-5-label"
+ xmlns:xlink="http://www.w3.org/1999/xlink"></svg>
+ <dialog id="dialog-1" open>
+ <p>Greetings, one and all!</p>
+ </dialog>
+ <dialog id="dialog-2" aria-label="" open>
+ <p>Greetings, one and all!</p>
+ </dialog>
+ <dialog id="dialog-3" aria-label="Greetings" open>
+ <p>Greetings, one and all!</p>
+ </dialog>
+ <dialog id="dialog-4" aria-labelledby="dialog-4-label" open>
+ <p id="dialog-4-label">Greetings, one and all!</p>
+ </dialog>
+ <div role="dialog" id="dialog-5">
+ <p>Greetings, one and all!</p>
+ </div>
+ <div role="dialog" id="dialog-6" aria-label="">
+ <p>Greetings, one and all!</p>
+ </div>
+ <div role="dialog" id="dialog-7" aria-label="Greetings">
+ <p>Greetings, one and all!</p>
+ </div>
+ <div role="dialog" id="dialog-8" aria-labelledby="dialog-8-label">
+ <p id="dialog-8-label">Greetings, one and all!</p>
+ </div>
+ <dialog id="dialog-9" aria-labelledby="dialog-9-label" open>
+ <p id="dialog-9-label"></p>
+ </dialog>
+ <div role="dialog" id="dialog-10" aria-labelledby="dialog-10-label">
+ <p id="dialog-10-label"></p>
+ </div>
+ <div id="editcombobox-1" role="combobox"></div>
+ <div id="editcombobox-2" aria-label="Choose a pet:" role="combobox"></div>
+ <div id="editcombobox-3-label">Choose a pet:</div><div id="editcombobox-3" aria-labelledby="editcombobox-3-label" role="combobox"></div>
+ <label>Customer name: <input id="entry-1"></label>
+ <input id="entry-2">
+ <input id="entry-3" aria-label="Customer name:">
+ <label>Customer name: </label><input id="entry-4">
+ <label for="entry-5">Customer name: </label><input id="entry-5">
+ <label id="entry-6-label">Customer name: </label><input id="entry-6" aria-labelledby="entry-6-label">
+ <div id="entry-7" role="textbox"></div>
+ <div id="entry-8" aria-label="Customer name:" role="textbox"></div>
+ <div id="entry-9-label">Customer name: </div><div id="entry-9" aria-labelledby="entry-9-label" role="textbox"></div>
+ <figure id="figure-1">
+ <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <figcaption>Figure 1: The four layers of awesome.</figcaption>
+ </figure>
+ <figure id="figure-2">
+ <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ </figure>
+ <div id="figure-3" role="figure" aria-labelledby="caption-figure-3">
+ <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <p id="caption-figure-3">Figure 1: The caption</p>
+ </div>
+ <div id="figure-4" role="figure" aria-labelledby="caption-figure-4">
+ <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <p id="caption-figure-4"></p>
+ </div>
+ <div id="figure-5" role="figure">
+ <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ </div>
+ <img id="img-1" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=">
+ <img id="img-2" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-label="alt text">
+ <p id="img-3-label">Label</p>
+ <img id="img-3" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="img-3-label">
+ <img id="img-4" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text">
+ <p id="img-5-label"></p>
+ <img id="img-5" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="img-5-label">
+ <div id="img-6" role="img"></div>
+ <div id="img-7" role="img" aria-label="alt text"></div>
+ <p id="img-8-label">Label</p>
+ <div id="img-8" role="img" aria-labelledby="img-8-label"></div>
+ <div id="img-9" role="img" aria-label=""></div>
+ <p id="img-10-label"></p>
+ <div id="img-10" role="img" aria-labelledby="img-10-label"></div>
+ <select>
+ <optgroup id="optgroup-1" label="Group 1">
+ <option>Option 1.1</option>
+ </optgroup>
+ <optgroup id="optgroup-2" label="">
+ <option>Option 2.1</option>
+ </optgroup>
+ <optgroup id="optgroup-3">
+ <option>Option 3.1</option>
+ </optgroup>
+ <optgroup id="optgroup-4" aria-label="Group 4">
+ <option>Option 4.1</option>
+ </optgroup>
+ <optgroup id="optgroup-5" aria-labelledby="optgroup-5-label">
+ <option id="optgroup-5-label">Option 5.1</option>
+ </optgroup>
+ </select>
+ <fieldset id="fieldset-1"><legend>Choose your favorite monster</legend></fieldset>
+ <fieldset id="fieldset-2"><legend></legend></fieldset>
+ <fieldset id="fieldset-3"></fieldset>
+ <fieldset id="fieldset-4" aria-label="Choose your favorite monster"></fieldset>
+ <p id="fieldset-5-label">Choose your favorite monster</p>
+ <fieldset id="fieldset-5" aria-labelledby="fieldset-5-label"></fieldset>
+ <h1 id="heading-1"></h1>
+ <h1 id="heading-2">Heading</h1>
+ <h1 id="heading-3">&nbsp;</h1>
+ <h1 id="heading-4" aria-label="Heading"></h1>
+ <h1 id="heading-5" aria-labelledby="heading-5-label"></h1>
+ <p id="heading-5-label">Heading</p>
+ <h1 id="heading-6" aria-label="Heading">H</h1>
+ <h1 id="heading-7" aria-labelledby="heading-7-label">H</h1>
+ <p id="heading-7-label">Heading</p>
+ <div role="heading" aria-level="1" id="heading-8"></div>
+ <div role="heading" aria-level="1" id="heading-9">Heading</div>
+ <div role="heading" aria-level="1" id="heading-10">&nbsp;</div>
+ <div role="heading" aria-level="1" id="heading-11" aria-label="Heading"></div>
+ <div role="heading" aria-level="1" id="heading-12" aria-labelledby="heading-12-label"></div>
+ <p id="heading-12-label">Heading</p>
+ <div role="heading" aria-level="1" id="heading-13" aria-label="Heading">H</div>
+ <div role="heading" aria-level="1" id="heading-14" aria-labelledby="heading-14-label">H</div>
+ <p id="heading-14-label">Heading</p>
+ <map name="imagemap">
+ <area alt="One" shape="rect" coords="0,0,14,28" href="1.html">
+ <area shape="rect" coords="14,0,28,28" href="2.html">
+ </map>
+ <img id="imagemap-1" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=">
+ <img id="imagemap-2" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-label="image map name">
+ <p id="imagemap-3-label">image map name</p>
+ <img id="imagemap-3" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="imagemap-3-label">
+ <img id="imagemap-4" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="image map name">
+ <p id="imagemap-5-label"></p>
+ <img id="imagemap-5" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="img-5-label">
+ <iframe id="iframe-1" title="IFrame Title" src="https://example.com"></iframe>
+ <iframe id="iframe-2" title="" src="https://example.com"></iframe>
+ <iframe id="iframe-3" src="https://example.com"></iframe>
+ <iframe id="iframe-4" aria-label="Bad Title" src="https://example.com"></iframe>
+ <iframe id="iframe-5" aria-label="Bad Title" title="Good Title" src="https://example.com"></iframe>
+ <object id="object-1" type="image/png" data="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="></object>
+ <object id="object-2" aria-label="Image object" type="image/png" data="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="></object>
+ <p id="object-3-label">Image object</p>
+ <object id="object-3" aria-labelledby="object-3-label" type="image/png" data="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="></object>
+ <object id="object-4" type="text/html" data="https://example.com"></object>
+ <embed id="embed-1" type="image/png" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=">
+ <embed id="embed-2" type="video/webm" src="data:video/webm,xxx">
+ <embed id="embed-3" aria-label="Embedded video" type="video/webm" src="data:video/webm,xxx">
+ <p id="embed-4-label">Embedded video</p>
+ <embed id="embed-4" aria-labelledby="embed-4-label" type="video/webm" src="data:video/webm,xxx">
+ <a id="link-1"></a>
+ <a id="link-2">Hello world</a>
+ <a id="link-3" href></a>
+ <a id="link-4" href>Hello world</a>
+ <a id="link-5" href=""></a>
+ <a id="link-6" href="">Hello world</a>
+ <a id="link-7" href="#"></a>
+ <a id="link-8" href="#">Hello world</a>
+ <a id="link-9" href="https://example.com"></a>
+ <a id="link-10" href="https://example.com">Hello world</a>
+ <a id="link-11" aria-label="Hello world" href="https://example.com"></a>
+ <p id="link-12-label">Hello world</p>
+ <a id="link-12" aria-labelledby="link-12-label" href="https://example.com"></a>
+ <div role="link" id="link-13"></div>
+ <div role="link" id="link-14">Hello world</div>
+ <div role="link" id="link-15" aria-label="Hello world"></div>
+ <p id="link-16-label">Hello world</p>
+ <div role="link" id="link-16" aria-labelledby="link-16-label"></div>
+ <p id="mglyph-3-label">Label</p>
+ <p id="mglyph-6-label"></p>
+ <math>
+ <mi><mglyph id="mglyph-1" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="/></mi>
+ <mi><mglyph id="mglyph-2" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-label="alt text"/></mi>
+ <mi><mglyph id="mglyph-3" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="mglyph-3-label"/></mi>
+ <mi><mglyph id="mglyph-4" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"/></mi>
+ <mi><mglyph id="mglyph-5" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt=""/></mi>
+ <mi><mglyph id="mglyph-6" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="mglyph-6-label"/></mi>
+ </math>
+ <span id="menuitem-1" role="menuitem"></span>
+ <span id="menuitem-2" aria-label="" role="menuitem"></span>
+ <span id="menuitem-3" aria-label="Menu Item" role="menuitem"></span>
+ <p id="menuitem-4-label">Menu Item</p>
+ <span id="menuitem-4" aria-labelledby="menuitem-4-label" role="menuitem"></span>
+ <p id="menuitem-5-label"></p>
+ <span id="menuitem-5" aria-labelledby="menuitem-5-label" role="menuitem"></span>
+ <span id="menuitem-6" role="menuitem">Menu Item</span>
+ <label for="listbox-1">Choose a pet:</label>
+ <select id="listbox-1" size="6">
+ <option id="option-1" value="">--Please choose an option--</option>
+ <option id="option-2" value="dog"></option>
+ <option id="option-3" value="cat">&nbsp;</option>
+ <option id="option-4" value="" label="--Please choose an option--"></option>
+ <option id="option-5" value="dog" label=""></option>
+ <option id="option-6" value="cat" label=" "></option>
+ </select>
+ <select id="listbox-2" size="2"></select>
+ <label>Choose a pet:</label><select id="listbox-3" size="2"></select>
+ <label>Choose a pet:<select id="listbox-4" size="2"></select></label>
+ <select id="listbox-5" aria-label="Choose a pet:" size="2"></select>
+ <label id="listbox-6-label">Choose a pet:</label><select id="listbox-6" aria-labelledby="listbox-6-label" size="2"></select>
+ <div role="listbox">
+ <div role="option" id="option-7">--Please choose an option--</div>
+ <div role="option" id="option-8"></div>
+ <div role="option" id="option-9">&nbsp;</div>
+ <div role="option" id="option-10" aria-label="--Please choose an option--"></div>
+ <div role="option" id="option-11" aria-label=""></div>
+ <div role="option" id="option-12" aria-label=" "></div>
+ <p id="option-13-label">--Please choose an option--</p>
+ <div role="option" id="option-13" aria-labelledby="option-13-label"></div>
+ <p id="option-14-label"></p>
+ <div role="option" id="option-14" aria-labelledby="option-14-label"></div>
+ <p id="option-15-label"> </p>
+ <div role="option" id="option-15" aria-labelledby="option-15-label"></div>
+ </div>
+ <span id="treeitem-1" role="treeitem"></span>
+ <span id="treeitem-2" aria-label="" role="treeitem"></span>
+ <span id="treeitem-3" aria-label="Tree Item" role="treeitem"></span>
+ <p id="treeitem-4-label">Tree Item</p>
+ <span id="treeitem-4" aria-labelledby="treeitem-4-label" role="treeitem"></span>
+ <p id="treeitem-5-label"></p>
+ <span id="treeitem-5" aria-labelledby="treeitem-5-label" role="treeitem"></span>
+ <span id="treeitem-6" role="treeitem">Tree Item</span>
+ <div role="tablist">
+ <span id="tab-1" role="tab"></span>
+ <span id="tab-2" aria-label="" role="tab"></span>
+ <span id="tab-3" aria-label="Tab" role="tab"></span>
+ <p id="tab-4-label">Tab</p>
+ <span id="tab-4" aria-labelledby="tab-4-label" role="tab"></span>
+ <p id="tab-5-label"></p>
+ <span id="tab-5" aria-labelledby="tab-5-label" role="tab"></span>
+ <span id="tab-6" role="tab">Tab</span>
+ </div>
+ <label>Password: <input type="password" id="password-1"></label>
+ <input type="password" id="password-2">
+ <input type="password" id="password-3" aria-label="Password:">
+ <label>Password: </label><input type="password" id="password-4">
+ <label for="password-5">Password: </label><input type="password" id="password-5">
+ <label id="password-6-label">Password: </label><input type="password" id="password-6" aria-labelledby="password-6-label">
+ <label>Progress: <progress id="progress-1"></progress></label>
+ <progress id="progress-2"></progress>
+ <progress id="progress-3" aria-label="Progress:"></progress>
+ <label>Progress: </label><progress id="progress-4"></progress>
+ <label for="progress-5">Progress: </label><progress id="progress-5"></progress>
+ <label id="progress-6-label">Progress: </label><progress id="progress-6" aria-labelledby="progress-6-label"></progress>
+ <label>Progress: <div role="progressbar" id="progress-7"></div></label>
+ <label id="progress-8-label">Progress: <div role="progressbar" id="progress-8" aria-labelledby="progress-8-label"></div></label>
+ <div role="progressbar" id="progress-9"></div>
+ <div role="progressbar" id="progress-10" aria-label="Progress:"></div>
+ <label>Progress: </label><div role="progressbar" id="progress-11"></div>
+ <label for="progress-12">Progress: </label><div role="progressbar" id="progress-12"></div>
+ <label id="progress-13-label">Progress: </label><div role="progressbar" id="progress-13" aria-labelledby="progress-13-label"></div>
+ <button id="button-1">hello</button>
+ <button id="button-2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" /></button>
+ <button id="button-3"></button>
+ <button id="button-4"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="" /></button>
+ <button id="button-5"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="hello" /></button>
+ <button id="button-6">&nbsp;</button>
+ <label>Button: <button id="button-7"></button></label>
+ <button id="button-8" aria-label="Button:"></button>
+ <label>Button: </label><button id="button-9"></button>
+ <label for="button-10">Button: </label><button id="button-10"></button>
+ <label id="button-11-label">Button: </label><button id="button-11" aria-labelledby="button-11-label"></button>
+ <label>Button: <div role="button" id="button-12"></div></label>
+ <label id="button-13-label">Button: <div role="button" id="button-13" aria-labelledby="button-13-label"></div></label>
+ <div role="button" id="button-14"></div>
+ <div role="button" id="button-15" aria-label="Button:"></div>
+ <label>Button: </label><div role="button" id="button-16"></div>
+ <label for="button-17">Button: </label><div role="button" id="button-17"></div>
+ <label id="button-18-label">Button: </label><div role="button" id="button-18" aria-labelledby="button-18-label"></div>
+ <label>Radio label: <input type="radio" id="radiobutton-1"></label>
+ <input type="radio" id="radiobutton-2">
+ <input type="radio" id="radiobutton-3" aria-label="Radio label:">
+ <label>Radio label: </label><input type="radio" id="radiobutton-4">
+ <label for="radiobutton-5">Radio label: </label><input type="radio" id="radiobutton-5">
+ <label id="radiobutton-6-label">Radio label: </label><input type="radio" id="radiobutton-6" aria-labelledby="radiobutton-6-label">
+ <div id="radiobutton-7" role="radio"></div>
+ <div id="radiobutton-8" aria-label="Radio label:" role="radio"></div>
+ <div id="radiobutton-9-label">Radio label: </div><div id="radiobutton-9" aria-labelledby="radiobutton-9-label" role="radio"></div>
+ <div role="menu">
+ <div id="menuitemradio-1" role="menuitemradio">hello</div>
+ <div id="menuitemradio-2" role="menuitemradio"></div>
+ <div id="menuitemradio-3" role="menuitemradio">&nbsp;</div>
+ </div>
+ <div role="rowheader" id="rowheader-8">Toy Story 3</div>
+ <div role="rowheader" id="rowheader-9"></div>
+ <div role="rowheader" id="rowheader-10">&nbsp;</div>
+ <div role="rowheader" id="rowheader-11" aria-label="Alladin"></div>
+ <div role="rowheader" id="rowheader-12" aria-label=""></div>
+ <div role="rowheader" id="rowheader-13" aria-label=" "></div>
+ <div role="rowheader" id="rowheader-14" aria-labelledby="columnheader-7-label"></div>
+ <label>Slider label: <input type="range" id="slider-1"></label>
+ <input type="range" id="slider-2">
+ <input type="range" id="slider-3" aria-label="Slider label:">
+ <label>Slider label: </label><input type="range" id="slider-4">
+ <label for="slider-5">Slider label: </label><input type="range" id="slider-5">
+ <label id="slider-6-label">Slider label: </label><input type="range" id="slider-6" aria-labelledby="slider-6-label">
+ <div id="slider-7" role="slider"></div>
+ <div id="slider-8" aria-label="Slider label:" role="slider"></div>
+ <div id="slider-9-label">Slider label: </div><div id="slider-9" aria-labelledby="slider-9-label" role="slider"></div>
+ <label>Spin button label: <input type="number" id="spinbutton-1"></label>
+ <input type="number" id="spinbutton-2">
+ <input type="number" id="spinbutton-3" aria-label="Spin button label:">
+ <label>Spin button label: </label><input type="number" id="spinbutton-4">
+ <label for="spinbutton-5">Spin button label: </label><input type="number" id="spinbutton-5">
+ <label id="spinbutton-6-label">Spin button label: </label><input type="number" id="spinbutton-6" aria-labelledby="spinbutton-6-label">
+ <div id="spinbutton-7" role="spinbutton"></div>
+ <div id="spinbutton-8" aria-label="Spin button label:" role="spinbutton"></div>
+ <div id="spinbutton-9-label">Spin button label: </div><div id="spinbutton-9" aria-labelledby="spinbutton-9-label" role="spinbutton"></div>
+ <div id="switch-1" role="switch"></div>
+ <div id="switch-2" aria-label="hello" role="switch"></div>
+ <div id="switch-3-label">hello</div><div id="switch-3" aria-labelledby="switch-3-label" role="switch"></div>
+ <label for="switch-4">hello</label><div id="switch-4" role="switch"></div>
+ <label>hello<div id="switch-5" role="switch"></div></label>
+ <label>Meter label: <meter id="meter-1"></meter></label>
+ <meter id="meter-2"></meter>
+ <meter id="meter-3" aria-label="Meter label:"></meter>
+ <label>Meter label: </label><meter id="meter-4"></meter>
+ <label for="meter-5">Meter label: </label><meter id="meter-5"></meter>
+ <label id="meter-6-label">Meter label: </label><meter id="meter-6" aria-labelledby="meter-6-label"></meter>
+ <div id="meter-7" role="meter"></div>
+ <div id="meter-8" aria-label="Meter label:" role="meter"></div>
+ <div id="meter-9-label">Meter label: </div><div id="meter-9" aria-labelledby="meter-9-label" role="meter"></div>
+ <button aria-pressed="true" id="togglebutton-1" >hello</button>
+ <button aria-pressed="true" id="togglebutton-2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" /></button>
+ <button aria-pressed="true" id="togglebutton-3"></button>
+ <button aria-pressed="true" id="togglebutton-4"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="" /></button>
+ <button aria-pressed="true" id="togglebutton-5"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="hello" /></button>
+ <button aria-pressed="true" id="togglebutton-6">&nbsp;</button>
+ <label>Button: <button aria-pressed="true" id="togglebutton-7"></button></label>
+ <button aria-pressed="true" id="togglebutton-8" aria-label="Button:"></button>
+ <label>Button: </label><button aria-pressed="true" id="togglebutton-9"></button>
+ <label for="togglebutton-10">Button: </label><button aria-pressed="true" id="togglebutton-10"></button>
+ <label id="togglebutton-11-label">Button: </label><button aria-pressed="true" id="togglebutton-11" aria-labelledby="togglebutton-11-label"></button>
+ <label>Button: <div role="button" aria-pressed="true" id="togglebutton-12"></div></label>
+ <label id="togglebutton-13-label">Button: <div role="button" aria-pressed="true" id="togglebutton-13" aria-labelledby="togglebutton-13-label"></div></label>
+ <div role="button" aria-pressed="true" id="togglebutton-14"></div>
+ <div role="button" aria-pressed="true" id="togglebutton-15" aria-label="Button:"></div>
+ <label>Button: </label><div role="button" aria-pressed="true" id="togglebutton-16"></div>
+ <label for="togglebutton-17">Button: </label><div role="button" aria-pressed="true" id="togglebutton-17"></div>
+ <label id="togglebutton-18-label">Button: </label><div role="button" aria-pressed="true" id="togglebutton-18" aria-labelledby="togglebutton-18-label"></div>
+ <span id="toolbar-1" role="toolbar" aria-label="Toolbar"></span>
+ <span id="toolbar-2" role="toolbar"></span>
+ <p id="toolbar-3-label"></p>
+ <span id="toolbar-3" role="toolbar" aria-labelledby="toolbar-3-label"></span>
+ <p id="toolbar-4-label">Toolbar</p>
+ <span id="toolbar-4" role="toolbar" aria-labelledby="toolbar-4-label"></span>
+ <svg id="svg-1" role="img" viewbox="0 0 100 10" height="10px">
+ <title id="siteLogoTitle">Site Logo</title>
+ <rect x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+ <svg id="svg-2" viewbox="0 0 100 10" height="10px">
+ <title id="siteLogoTitle">Site Logo</title>
+ <rect x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+ <svg id="svg-3" role="img" viewbox="0 0 100 10" height="10px">
+ <rect x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+ <svg id="svg-4" viewbox="0 0 100 10" height="10px">
+ <rect x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+ <svg id="svg-5" aria-label="foo" viewbox="0 0 100 10" height="10px">
+ <rect id="svg-6" aria-label="bar" x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+ <svg id="svg-7" viewbox="0 0 100 10" height="10px">
+ <title id="siteLogoTitle">Site Logo</title>
+ <rect id="svg-8" aria-label="foo" x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+ <svg id="svg-9" role="img" viewbox="0 0 100 10" height="10px">
+ <title id="siteLogoTitle">Site Logo</title>
+ <rect aria-label="foo" id="svg-10" x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+ <svg id="svg-11" role="img" viewbox="0 0 100 10" height="10px">
+ <rect aria-label="foo" id="svg-12" x="0" y="00" width="100" height="10" fill="red"></rect>
+ </svg>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/doc_accessibility_text_label_audit_frame.html b/devtools/server/tests/browser/doc_accessibility_text_label_audit_frame.html
new file mode 100644
index 0000000000..34a32abeb2
--- /dev/null
+++ b/devtools/server/tests/browser/doc_accessibility_text_label_audit_frame.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <frameset cols="50%,50%">
+ <frame id="frame-1" src="https://example.com"></frame>
+ <frame id="frame-2" aria-label="Label" src="https://example.com"></frame>
+ </frameset>
+</html>
diff --git a/devtools/server/tests/browser/doc_allocations.html b/devtools/server/tests/browser/doc_allocations.html
new file mode 100644
index 0000000000..a5c9ea6d41
--- /dev/null
+++ b/devtools/server/tests/browser/doc_allocations.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script>
+"use strict";
+
+window.allocs = [];
+window.onload = function() {
+ function allocator() {
+ for (let i = 0; i < 1000; i++) {
+ window.allocs.push({});
+ }
+ }
+
+ window.setInterval(allocator, 1);
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/doc_compatibility.html b/devtools/server/tests/browser/doc_compatibility.html
new file mode 100644
index 0000000000..82cee286b5
--- /dev/null
+++ b/devtools/server/tests/browser/doc_compatibility.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<style>
+ div {
+ color: lime;
+ }
+
+ #id-clip {
+ clip: rect(10px, 10px, 10px, 10px);
+ }
+
+ .class-clip {
+ clip: rect(5px, 5px, 5px, 5px);
+ }
+
+ .class-user-select {
+ -moz-user-select: all;
+ }
+
+ .duplicate {
+ clip: rect(10px, 10px, 10px, 10px);
+ clip: rect(5px, 5px, 5px, 5px);
+ clip: rect(2px, 2px, 2px, 2px);
+ }
+</style>
+<div></div>
+<div class="class-user-select"></div>
+<div id="id-clip" class="class-clip class-user-select"></div>
+<div class="duplicate"></div>
diff --git a/devtools/server/tests/browser/doc_force_cc.html b/devtools/server/tests/browser/doc_force_cc.html
new file mode 100644
index 0000000000..22b1eb4071
--- /dev/null
+++ b/devtools/server/tests/browser/doc_force_cc.html
@@ -0,0 +1,32 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>Performance tool + cycle collection test page</title>
+ </head>
+
+ <body>
+ <script type="text/javascript">
+ "use strict";
+
+ /* global test */
+ window.test = function() {
+ document.body.expando1 = { cycle: document.body };
+ SpecialPowers.Cu.forceCC();
+
+ document.body.expando2 = { cycle: document.body };
+ SpecialPowers.Cu.forceCC();
+
+ document.body.expando3 = { cycle: document.body };
+ SpecialPowers.Cu.forceCC();
+
+ setTimeout(window.test, 100);
+ };
+ test();
+ </script>
+ </body>
+
+</html>
diff --git a/devtools/server/tests/browser/doc_force_gc.html b/devtools/server/tests/browser/doc_force_gc.html
new file mode 100644
index 0000000000..7dee110501
--- /dev/null
+++ b/devtools/server/tests/browser/doc_force_gc.html
@@ -0,0 +1,31 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>Performance tool + garbage collection test page</title>
+ </head>
+
+ <body>
+ <script type="text/javascript">
+ "use strict";
+
+ var x = 1;
+ /* global test */
+ window.test = function() {
+ SpecialPowers.Cu.forceGC();
+ document.body.style.borderTop = x + "px solid red";
+ x = 1 ^ x;
+ // flush pending reflows
+ document.body.innerHeight;
+
+ // Prevent this script from being garbage collected.
+ setTimeout(window.test, 100);
+ };
+ test();
+ </script>
+ </body>
+
+</html>
diff --git a/devtools/server/tests/browser/doc_iframe.html b/devtools/server/tests/browser/doc_iframe.html
new file mode 100644
index 0000000000..445361f7fa
--- /dev/null
+++ b/devtools/server/tests/browser/doc_iframe.html
@@ -0,0 +1,17 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>iframe test page</title>
+ </head>
+
+ <body>
+ <iframe id="better-not-ask" src="data:text/html,<iframe src='data:text/html,foo'></iframe>"></iframe>
+ <!-- This page is loaded on an example.org subdomain, so we switch to .com -->
+ <iframe id="remote-frame" src="http://example.com/browser/devtools/server/tests/browser/doc_iframe_content.html"></iframe>
+ </body>
+
+</html>
diff --git a/devtools/server/tests/browser/doc_iframe2.html b/devtools/server/tests/browser/doc_iframe2.html
new file mode 100644
index 0000000000..2255490f26
--- /dev/null
+++ b/devtools/server/tests/browser/doc_iframe2.html
@@ -0,0 +1,15 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>Sub document page</title>
+ </head>
+
+ <body>
+ Iframe document
+ </body>
+
+</html>
diff --git a/devtools/server/tests/browser/doc_iframe_content.html b/devtools/server/tests/browser/doc_iframe_content.html
new file mode 100644
index 0000000000..6f80e4dd6d
--- /dev/null
+++ b/devtools/server/tests/browser/doc_iframe_content.html
@@ -0,0 +1,14 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>Frame for browser_resource_list-remote-frames.js</title>
+ </head>
+
+ <body>
+ <div>Remote frame content</div>
+ </body>
+</html>
diff --git a/devtools/server/tests/browser/doc_innerHTML.html b/devtools/server/tests/browser/doc_innerHTML.html
new file mode 100644
index 0000000000..e58b32f51e
--- /dev/null
+++ b/devtools/server/tests/browser/doc_innerHTML.html
@@ -0,0 +1,21 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>Performance tool + innerHTML test page</title>
+ </head>
+
+ <body>
+ <script type="text/javascript">
+ "use strict";
+ window.test = function() {
+ document.body.innerHTML = "<h1>LOL</h1>";
+ };
+ setInterval(window.test, 100);
+ </script>
+ </body>
+
+</html>
diff --git a/devtools/server/tests/browser/error-actor.js b/devtools/server/tests/browser/error-actor.js
new file mode 100644
index 0000000000..3872d8ad96
--- /dev/null
+++ b/devtools/server/tests/browser/error-actor.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+
+/**
+ * Test actor designed to check that clients are properly notified of errors when calling
+ * methods on old style actors.
+ */
+class ErrorActor extends Actor {
+ constructor(conn, tab) {
+ super(conn, { typeName: "error", methods: [] });
+ this.tab = tab;
+ this.requestTypes = {
+ error: this.onError,
+ };
+ }
+ onError() {
+ throw new Error("error");
+ }
+}
+
+exports.ErrorActor = ErrorActor;
diff --git a/devtools/server/tests/browser/grid.html b/devtools/server/tests/browser/grid.html
new file mode 100644
index 0000000000..3bd0e1ec26
--- /dev/null
+++ b/devtools/server/tests/browser/grid.html
@@ -0,0 +1,42 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>Grid test page</title>
+ <style type='text/css'>
+ #grid {
+ display: grid;
+ grid-template-columns: [col-1 col-start-1] 100px [col-2] 100px;
+ grid-template-rows: 100px 100px;
+ grid-template-areas: ". header"
+ "sidebar content";
+ }
+ #cell1 {
+ grid-column: 1;
+ grid-row: 1;
+ }
+ #cell2 {
+ grid-column: 2;
+ grid-row: 1;
+ }
+ #cell3 {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ #cell4 {
+ grid-column: 2;
+ grid-row: 2;
+ }
+ </style>
+</head>
+<body>
+ <div id="grid">
+ <div id="cell1">cell1</div>
+ <div id="cell2">cell2</div>
+ <div id="cell3">cell3</div>
+ <div id="cell4">cell4</div>
+ </div>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/head.js b/devtools/server/tests/browser/head.js
new file mode 100644
index 0000000000..aba6d578f2
--- /dev/null
+++ b/devtools/server/tests/browser/head.js
@@ -0,0 +1,337 @@
+/* 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 {
+ ActorRegistry,
+} = require("resource://devtools/server/actors/utils/actor-registry.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+
+const PATH = "browser/devtools/server/tests/browser/";
+const TEST_DOMAIN = "http://test1.example.org";
+const MAIN_DOMAIN = `${TEST_DOMAIN}/${PATH}`;
+const ALT_DOMAIN = "http://sectest1.example.org/" + PATH;
+const ALT_DOMAIN_SECURED = "https://sectest1.example.org:443/" + PATH;
+
+// GUID to be used as a separator in compound keys. This must match the same
+// constant in devtools/server/actors/resources/storage/index.js,
+// devtools/client/storage/ui.js and devtools/client/storage/test/head.js
+const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}";
+
+// All tests are asynchronous.
+waitForExplicitFinish();
+
+// does almost the same thing as addTab, but directly returns an object
+async function addTabTarget(url) {
+ info(`Adding a new tab with URL: ${url}`);
+ const tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url));
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ info(`Tab added a URL ${url} loaded`);
+ return createAndAttachTargetForTab(tab);
+}
+
+async function initAnimationsFrontForUrl(url) {
+ const { inspector, walker, target } = await initInspectorFront(url);
+ const animations = await target.getFront("animations");
+
+ return { inspector, walker, animations, target };
+}
+
+async function initLayoutFrontForUrl(url) {
+ const { inspector, walker, target } = await initInspectorFront(url);
+ const layout = await walker.getLayoutInspector();
+
+ return { inspector, walker, layout, target };
+}
+
+async function initAccessibilityFrontsForUrl(
+ url,
+ { enableByDefault = true } = {}
+) {
+ const { inspector, walker, target } = await initInspectorFront(url);
+ const parentAccessibility = await target.client.mainRoot.getFront(
+ "parentaccessibility"
+ );
+ const accessibility = await target.getFront("accessibility");
+ const a11yWalker = accessibility.accessibleWalkerFront;
+ if (enableByDefault) {
+ await parentAccessibility.enable();
+ }
+
+ return {
+ inspector,
+ walker,
+ accessibility,
+ parentAccessibility,
+ a11yWalker,
+ target,
+ };
+}
+
+function initDevToolsServer() {
+ try {
+ // Sometimes devtools server does not get destroyed correctly by previous
+ // tests.
+ DevToolsServer.destroy();
+ } catch (e) {
+ info(`DevToolsServer destroy error: ${e}\n${e.stack}`);
+ }
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+}
+
+async function initPerfFront() {
+ initDevToolsServer();
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ await waitUntilClientConnected(client);
+ const front = await client.mainRoot.getFront("perf");
+ return { front, client };
+}
+
+async function initInspectorFront(url) {
+ const target = await addTabTarget(url);
+ const inspector = await target.getFront("inspector");
+ const walker = inspector.walker;
+
+ return { inspector, walker, target };
+}
+
+/**
+ * Wait until a DevToolsClient is connected.
+ * @param {DevToolsClient} client
+ * @return {Promise} Resolves when connected.
+ */
+function waitUntilClientConnected(client) {
+ return client.once("connected");
+}
+
+/**
+ * Wait for eventName on target.
+ * @param {Object} target An observable object that either supports on/off or
+ * addEventListener/removeEventListener
+ * @param {String} eventName
+ * @param {Boolean} useCapture Optional, for addEventListener/removeEventListener
+ * @return A promise that resolves when the event has been handled
+ */
+function once(target, eventName, useCapture = false) {
+ info("Waiting for event: '" + eventName + "' on " + target + ".");
+
+ return new Promise(resolve => {
+ for (const [add, remove] of [
+ ["addEventListener", "removeEventListener"],
+ ["addListener", "removeListener"],
+ ["on", "off"],
+ ]) {
+ if (add in target && remove in target) {
+ target[add](
+ eventName,
+ function onEvent(...aArgs) {
+ info("Got event: '" + eventName + "' on " + target + ".");
+ target[remove](eventName, onEvent, useCapture);
+ resolve(...aArgs);
+ },
+ useCapture
+ );
+ break;
+ }
+ }
+ });
+}
+
+/**
+ * Forces GC, CC and Shrinking GC to get rid of disconnected docshells and
+ * windows.
+ */
+function forceCollections() {
+ Cu.forceGC();
+ Cu.forceCC();
+ Cu.forceShrinkingGC();
+}
+
+registerCleanupFunction(function tearDown() {
+ Services.cookies.removeAll();
+
+ while (gBrowser.tabs.length > 1) {
+ gBrowser.removeCurrentTab();
+ }
+});
+
+function idleWait(time) {
+ return DevToolsUtils.waitForTime(time);
+}
+
+function busyWait(time) {
+ const start = Date.now();
+ let stack;
+ while (Date.now() - start < time) {
+ stack = Components.stack; // eslint-disable-line no-unused-vars
+ }
+}
+
+/**
+ * Waits until a predicate returns true.
+ *
+ * @param function predicate
+ * Invoked once in a while until it returns true.
+ * @param number interval [optional]
+ * How often the predicate is invoked, in milliseconds.
+ */
+function waitUntil(predicate, interval = 10) {
+ if (predicate()) {
+ return Promise.resolve(true);
+ }
+ return new Promise(resolve => {
+ setTimeout(function () {
+ waitUntil(predicate).then(() => resolve(true));
+ }, interval);
+ });
+}
+
+function waitForMarkerType(
+ front,
+ types,
+ predicate,
+ unpackFun = (name, data) => data.markers,
+ eventName = "timeline-data"
+) {
+ types = [].concat(types);
+ predicate =
+ predicate ||
+ function () {
+ return true;
+ };
+ let filteredMarkers = [];
+
+ return new Promise(resolve => {
+ info("Waiting for markers of type: " + types);
+
+ function handler(name, data) {
+ if (typeof name === "string" && name !== "markers") {
+ return;
+ }
+
+ const markers = unpackFun(name, data);
+ info("Got markers");
+
+ filteredMarkers = filteredMarkers.concat(
+ markers.filter(m => types.includes(m.name))
+ );
+
+ if (
+ types.every(t => filteredMarkers.some(m => m.name === t)) &&
+ predicate(filteredMarkers)
+ ) {
+ front.off(eventName, handler);
+ resolve(filteredMarkers);
+ }
+ }
+ front.on(eventName, handler);
+ });
+}
+
+function getCookieId(name, domain, path) {
+ return `${name}${SEPARATOR_GUID}${domain}${SEPARATOR_GUID}${path}`;
+}
+
+/**
+ * Trigger DOM activity and wait for the corresponding accessibility event.
+ * @param {Object} emitter Devtools event emitter, usually a front.
+ * @param {Sting} name Accessibility event in question.
+ * @param {Function} handler Accessibility event handler function with checks.
+ * @param {Promise} task A promise that resolves when DOM activity is done.
+ */
+async function emitA11yEvent(emitter, name, handler, task) {
+ const promise = emitter.once(name, handler);
+ await task();
+ await promise;
+}
+
+/**
+ * Check that accessibilty front is correct and its attributes are also
+ * up-to-date.
+ * @param {Object} front Accessibility front to be tested.
+ * @param {Object} expected A map of a11y front properties to be verified.
+ * @param {Object} expectedFront Expected accessibility front.
+ */
+function checkA11yFront(front, expected, expectedFront) {
+ ok(front, "The accessibility front is created");
+
+ if (expectedFront) {
+ is(front, expectedFront, "Matching accessibility front");
+ }
+
+ // Clone the front so we could modify some values for comparison.
+ front = Object.assign(front);
+ for (const key in expected) {
+ if (key === "checks") {
+ const { CONTRAST } = front[key];
+ // Contrast values are rounded to two digits after the decimal point.
+ if (CONTRAST && CONTRAST.value) {
+ CONTRAST.value = parseFloat(CONTRAST.value.toFixed(2));
+ }
+ }
+
+ if (["actions", "states", "attributes", "checks"].includes(key)) {
+ SimpleTest.isDeeply(
+ front[key],
+ expected[key],
+ `Accessible Front has correct ${key}`
+ );
+ } else {
+ is(front[key], expected[key], `accessibility front has correct ${key}`);
+ }
+ }
+}
+
+function getA11yInitOrShutdownPromise() {
+ return new Promise(resolve => {
+ const observe = (subject, topic, data) => {
+ Services.obs.removeObserver(observe, "a11y-init-or-shutdown");
+ resolve(data);
+ };
+ Services.obs.addObserver(observe, "a11y-init-or-shutdown");
+ });
+}
+
+/**
+ * Wait for accessibility service to shut down. We consider it shut down when
+ * an "a11y-init-or-shutdown" event is received with a value of "0".
+ */
+async function waitForA11yShutdown(parentAccessibility) {
+ await parentAccessibility.disable();
+ if (!Services.appinfo.accessibilityEnabled) {
+ return;
+ }
+
+ await getA11yInitOrShutdownPromise().then(data =>
+ data === "0" ? Promise.resolve() : Promise.reject()
+ );
+}
+
+/**
+ * Wait for accessibility service to initialize. We consider it initialized when
+ * an "a11y-init-or-shutdown" event is received with a value of "1".
+ */
+async function waitForA11yInit() {
+ if (Services.appinfo.accessibilityEnabled) {
+ return;
+ }
+
+ await getA11yInitOrShutdownPromise().then(data =>
+ data === "1" ? Promise.resolve() : Promise.reject()
+ );
+}
diff --git a/devtools/server/tests/browser/inspector-helpers.js b/devtools/server/tests/browser/inspector-helpers.js
new file mode 100644
index 0000000000..0c05432b98
--- /dev/null
+++ b/devtools/server/tests/browser/inspector-helpers.js
@@ -0,0 +1,161 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* exported assertOwnershipTrees, checkMissing, waitForMutation,
+ isSrcChange, isUnretained, isChildList */
+
+function serverOwnershipTree(walkerArg) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[walkerArg.actorID]],
+ function (actorID) {
+ const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+ );
+ const {
+ DevToolsServer,
+ } = require("resource://devtools/server/devtools-server.js");
+ const {
+ DocumentWalker,
+ } = require("resource://devtools/server/actors/inspector/document-walker.js");
+
+ // Convert actorID to current compartment string otherwise
+ // searchAllConnectionsForActor is confused and won't find the actor.
+ actorID = String(actorID);
+ const serverWalker = DevToolsServer.searchAllConnectionsForActor(actorID);
+
+ function sortOwnershipChildrenContentScript(children) {
+ return children.sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ function serverOwnershipSubtree(walker, node) {
+ const actor = walker.getNode(node);
+ if (!actor) {
+ return undefined;
+ }
+
+ const children = [];
+ const docwalker = new DocumentWalker(node, content);
+ let child = docwalker.firstChild();
+ while (child) {
+ const item = serverOwnershipSubtree(walker, child);
+ if (item) {
+ children.push(item);
+ }
+ child = docwalker.nextSibling();
+ }
+ return {
+ name: actor.actorID,
+ children: sortOwnershipChildrenContentScript(children),
+ };
+ }
+ return {
+ root: serverOwnershipSubtree(serverWalker, serverWalker.rootDoc),
+ orphaned: [...serverWalker._orphaned].map(o =>
+ serverOwnershipSubtree(serverWalker, o.rawNode)
+ ),
+ retained: [...serverWalker._retainedOrphans].map(o =>
+ serverOwnershipSubtree(serverWalker, o.rawNode)
+ ),
+ };
+ }
+ );
+}
+
+function sortOwnershipChildren(children) {
+ return children.sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function clientOwnershipSubtree(node) {
+ return {
+ name: node.actorID,
+ children: sortOwnershipChildren(
+ node.treeChildren().map(child => clientOwnershipSubtree(child))
+ ),
+ };
+}
+
+function clientOwnershipTree(walker) {
+ return {
+ root: clientOwnershipSubtree(walker.rootNode),
+ orphaned: [...walker._orphaned].map(o => clientOwnershipSubtree(o)),
+ retained: [...walker._retainedOrphans].map(o => clientOwnershipSubtree(o)),
+ };
+}
+
+function ownershipTreeSize(tree) {
+ let size = 1;
+ for (const child of tree.children) {
+ size += ownershipTreeSize(child);
+ }
+ return size;
+}
+
+async function assertOwnershipTrees(walker) {
+ const serverTree = await serverOwnershipTree(walker);
+ const clientTree = clientOwnershipTree(walker);
+ is(
+ JSON.stringify(clientTree, null, " "),
+ JSON.stringify(serverTree, null, " "),
+ "Server and client ownership trees should match."
+ );
+
+ return ownershipTreeSize(clientTree.root);
+}
+
+// Verify that an actorID is inaccessible both from the client library and the server.
+async function checkMissing({ client }, actorID) {
+ const front = client.getFrontByID(actorID);
+ ok(
+ !front,
+ "Front shouldn't be accessible from the client for actorID: " + actorID
+ );
+
+ try {
+ await client.request({
+ to: actorID,
+ type: "request",
+ });
+ ok(false, "The actor wasn't missing as the request worked");
+ } catch (e) {
+ is(
+ e.error,
+ "noSuchActor",
+ "node list actor should no longer be contactable."
+ );
+ }
+}
+
+// Load mutations aren't predictable, so keep accumulating mutations until
+// the one we're looking for shows up.
+function waitForMutation(walker, test, mutations = []) {
+ return new Promise(resolve => {
+ for (const change of mutations) {
+ if (test(change)) {
+ resolve(mutations);
+ }
+ }
+
+ walker.once("mutations", newMutations => {
+ waitForMutation(walker, test, mutations.concat(newMutations)).then(
+ finalMutations => {
+ resolve(finalMutations);
+ }
+ );
+ });
+ });
+}
+
+function isSrcChange(change) {
+ return change.type === "attributes" && change.attributeName === "src";
+}
+
+function isUnretained(change) {
+ return change.type === "unretained";
+}
+
+function isChildList(change) {
+ return change.type === "childList";
+}
diff --git a/devtools/server/tests/browser/inspector-isScrollable-data.html b/devtools/server/tests/browser/inspector-isScrollable-data.html
new file mode 100644
index 0000000000..07caabd894
--- /dev/null
+++ b/devtools/server/tests/browser/inspector-isScrollable-data.html
@@ -0,0 +1,79 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Inspector test of isScrollable</title>
+ <style>
+ /* "e" is our custom tag name for "element" */
+ e {
+ background: lightgray;
+ display: inline-block;
+ margin: 10px;
+ padding: 0;
+ border: 0;
+ width: 100px;
+ height: 100px;
+ overflow: auto;
+ }
+
+ /* "c" is our custom tag name for "child" */
+ c {
+ display: block;
+ background: green;
+ }
+
+ .fixedSize {
+ width: 10px;
+ height: 10px;
+ }
+
+ .target {
+ background: red;
+ }
+ </style>
+</head>
+<body id="body">
+<e id="no_children"></e>
+
+<e id="one_child_no_overflow">
+ <c></c>
+</e>
+
+<e id="margin_left_overflow">
+ <c class="target" style="margin-left:100px">abcd</c>
+</e>
+
+<e id="transform_overflow">
+ <c class="target" style="transform: translate(50px)">abcd</c>
+</e>
+
+<e id="nested_overflow">
+ <c>
+ <c class="target" style="margin-left:100px">abcd</c>
+ </c>
+</e>
+
+<e id="intermediate_overflow">
+ <c class="fixedSize target" style="margin-left:100px">
+ <c></c>
+ </c>
+</e>
+
+<e id="multiple_overflow_at_different_depths">
+ <c class="fixedSize target" style="margin-left:100px">
+ <c></c>
+ </c>
+ <c style="margin-left:100px">
+ <c class="target">abcd</c>
+ </c>
+</e>
+
+<e id="overflow_hidden" style="overflow:hidden">
+ <c class="target" style="margin-left:100px">abcd</c>
+</e>
+
+<e id="scrollbar_none" style="scrollbar-width:none">
+ <c class="target" style="margin-left:100px">abcd</c>
+</e>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/inspector-search-data.html b/devtools/server/tests/browser/inspector-search-data.html
new file mode 100644
index 0000000000..784dcb7c9b
--- /dev/null
+++ b/devtools/server/tests/browser/inspector-search-data.html
@@ -0,0 +1,54 @@
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Inspector Search Test Data</title>
+ <style>
+ #pseudo {
+ display: block;
+ margin: 0;
+ }
+ #pseudo:before {
+ content: "before element";
+ }
+ #pseudo:after {
+ content: "after element";
+ }
+ </style>
+ <script type="text/javascript">
+ "use strict";
+
+ window.onload = function() {
+ window.opener.postMessage("ready", "*");
+ };
+ </script>
+</head>
+</body>
+ <!-- A comment
+ spread across multiple lines -->
+
+ <img width="100" height="100" src="large-image.jpg" />
+
+ <h1 id="pseudo">Heading 1</h1>
+ <p>A p tag with the text 'h1' inside of it.
+ <strong>A strong h1 result</strong>
+ </p>
+
+ <div id="arrows" northwest="↖" northeast="↗" southeast="↘" southwest="↙">
+ Unicode arrows
+ </div>
+
+ <h2>Heading 2</h2>
+ <h2>Heading 2</h2>
+ <h2>Heading 2</h2>
+
+ <h3>Heading 3</h3>
+ <h3>Heading 3</h3>
+ <h3>Heading 3</h3>
+
+ <h4>Heading 4</h4>
+ <h4>Heading 4</h4>
+ <h4>Heading 4</h4>
+
+ <div class="💩" id="💩" 💩="💩"></div>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/inspector-shadow.html b/devtools/server/tests/browser/inspector-shadow.html
new file mode 100644
index 0000000000..eb600548e2
--- /dev/null
+++ b/devtools/server/tests/browser/inspector-shadow.html
@@ -0,0 +1,117 @@
+<!doctype html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Inspector (empty page)</title>
+ <script>
+ "use strict";
+
+ window.onload = function() {
+ customElements.define("test-empty", class extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({mode: "open"});
+ }
+ });
+
+ customElements.define("test-empty-closed", class extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({mode: "closed"});
+ }
+ });
+
+ customElements.define("test-children", class extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({mode: "open"});
+ this.shadowRoot.innerHTML = `
+ <h1>One child</h1>
+ <p>A second child</p>`;
+ }
+ });
+
+ customElements.define("test-named-slot", class extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({mode: "open"});
+ this.shadowRoot.innerHTML = `
+ <h1>With slot</h1>
+ <slot name="slot1"></slot>`;
+ }
+ });
+
+ customElements.define("test-slot", class extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({mode: "open"});
+ this.shadowRoot.innerHTML = `
+ <style>
+ slot::before { content: "[SLOT BEFORE]"; color: red; }
+ slot::after { content: "[SLOT AFTER]"; color: blue; }
+ </style>
+ <slot></slot>`;
+ }
+ });
+
+ customElements.define("test-simple-slot", class extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: "open"});
+ this.shadowRoot.innerHTML = "<slot></slot>";
+ }
+ });
+ };
+ </script>
+ <style>
+ #host-pseudo::before { content: "[HOST BEFORE]"; color: red; }
+ #host-pseudo::after { content: "[HOST AFTER]"; color: blue; }
+ </style>
+</head>
+<body>
+ <test-empty id="empty"></test-empty>
+
+ <hr>
+
+ <test-empty id="one-child">
+ <h1>One child</h1>
+ </test-empty>
+
+ <hr>
+
+ <test-children id="shadow-children"></test-children>
+
+ <hr>
+
+ <test-named-slot id="named-slot">
+ <p class="slotted" slot="slot1">Slotted</p>
+ </test-named-slot>
+
+ <hr>
+
+ <test-slot id="slot-pseudo">
+ <span class="has-before">Slotted</span>
+ </test-slot>
+
+ <hr>
+
+ <test-empty id="host-pseudo"></test-empty>
+
+ <hr>
+
+ <test-empty id="mode-open"></test-empty>
+ <test-empty-closed id="mode-closed"></test-empty-closed>
+
+ <hr>
+
+ <test-simple-slot id="slot-inline-text">
+ Lorem ipsum
+ </test-simple-slot>
+
+ <hr>
+ <video id="video-controls" controls></video>
+ <video id="video-controls-with-children" controls>
+ <div>some content</div>
+ </video>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/inspector-traversal-data.html b/devtools/server/tests/browser/inspector-traversal-data.html
new file mode 100644
index 0000000000..6f025747ec
--- /dev/null
+++ b/devtools/server/tests/browser/inspector-traversal-data.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Inspector Traversal Test Data</title>
+ <style type="text/css">
+ #pseudo::before {
+ content: "before";
+ }
+ #pseudo::after {
+ content: "after";
+ }
+ #pseudo-empty::before {
+ content: "before an empty element";
+ }
+ #shadow::before {
+ content: "Testing ::before on a shadow host";
+ }
+ </style>
+ <script type="text/javascript">
+ "use strict";
+
+ window.onload = function() {
+ // Set up a basic shadow DOM
+ const host = document.querySelector("#shadow");
+ const root = host.attachShadow({ mode: "open" });
+
+ const h3 = document.createElement("h3");
+ h3.append("Shadow ");
+
+ const em = document.createElement("em");
+ em.append("DOM");
+
+ const select = document.createElement("select");
+ select.setAttribute("multiple", "");
+ h3.appendChild(em);
+ root.appendChild(h3);
+ root.appendChild(select);
+
+ // Put a copy of the body in an iframe to test frame traversal.
+ const body = document.querySelector("body");
+ const data = "data:text/html,<html>" + body.outerHTML + "<html>";
+ const iframe = document.createElement("iframe");
+ iframe.setAttribute("id", "childFrame");
+ iframe.src = data;
+ body.appendChild(iframe);
+ };
+ </script>
+</head>
+<body style="background-color:white">
+ <h1>Inspector Actor Tests</h1>
+ <span id="longstring">longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong</span>
+ <span id="shortstring">short</span>
+ <span id="empty"></span>
+ <div id="longlist" data-test="exists">
+ <div id="a">a</div>
+ <div id="b">b</div>
+ <div id="c">c</div>
+ <div id="d">d</div>
+ <div id="e">e</div>
+ <div id="f">f</div>
+ <div id="g">g</div>
+ <div id="h">h</div>
+ <div id="i">i</div>
+ <div id="j">j</div>
+ <div id="k">k</div>
+ <div id="l">l</div>
+ <div id="m">m</div>
+ <div id="n">n</div>
+ <div id="o">o</div>
+ <div id="p">p</div>
+ <div id="q">q</div>
+ <div id="r">r</div>
+ <div id="s">s</div>
+ <div id="t">t</div>
+ <div id="u">u</div>
+ <div id="v">v</div>
+ <div id="w">w</div>
+ <div id="x">x</div>
+ <div id="y">y</div>
+ <div id="z">z</div>
+ </div>
+ <div id="longlist-sibling">
+ <div id="longlist-sibling-firstchild"></div>
+ </div>
+ <p id="edit-html"></p>
+
+ <select multiple><option>one</option><option>two</option></select>
+ <div id="pseudo"><span>middle</span></div>
+ <div id="pseudo-empty"></div>
+ <div id="shadow">light dom</div>
+ <object>
+ <div id="1"></div>
+ </object>
+ <div class="node-to-duplicate"></div>
+ <div id="scroll-into-view" style="margin-top: 1000px;">scroll</div>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/storage-cookies-same-name.html b/devtools/server/tests/browser/storage-cookies-same-name.html
new file mode 100644
index 0000000000..235c8a451f
--- /dev/null
+++ b/devtools/server/tests/browser/storage-cookies-same-name.html
@@ -0,0 +1,29 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector cookies with duplicate names</title>
+</head>
+<body onload="createCookies()">
+<script type="application/javascript">
+"use strict";
+// eslint-disable-next-line no-unused-vars
+function createCookies() {
+ document.cookie = "name=value1;path=/;";
+ document.cookie = "name=value2;path=/path2/;";
+ document.cookie = "name=value3;path=/path3/;";
+}
+
+window.removeCookie = function (name) {
+ document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
+};
+
+window.clearCookies = function () {
+ const cookies = document.cookie;
+ for (const cookie of cookies.split(";")) {
+ window.removeCookie(cookie.split("=")[0]);
+ }
+};
+</script>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/storage-dynamic-windows.html b/devtools/server/tests/browser/storage-dynamic-windows.html
new file mode 100644
index 0000000000..22df8a255e
--- /dev/null
+++ b/devtools/server/tests/browser/storage-dynamic-windows.html
@@ -0,0 +1,117 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 965872 - Storage inspector actor with cookies, local storage and session storage.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for listing hosts and storages</title>
+</head>
+<body>
+<iframe src="http://sectest1.example.org/browser/devtools/server/tests/browser/storage-unsecured-iframe.html"></iframe>
+<script type="application/javascript">
+"use strict";
+const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1];
+const cookieExpiresTime1 = 2000000000000;
+const cookieExpiresTime2 = 2000000001000;
+// Setting up some cookies to eat.
+document.cookie = "c1=foobar; expires=" +
+ new Date(cookieExpiresTime1).toGMTString() + "; path=/browser";
+document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname;
+document.cookie = "c3=foobar-2; expires=" +
+ new Date(cookieExpiresTime2).toGMTString() + "; path=/";
+// ... and some local storage items ..
+localStorage.setItem("ls1", "foobar");
+localStorage.setItem("ls2", "foobar-2");
+// ... and finally some session storage items too
+sessionStorage.setItem("ss1", "foobar-3");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const dbResult = event.target.result;
+ const store1 = dbResult.createObjectStore("obj1", { keyPath: "id" });
+ store1.createIndex("name", "name", { unique: false });
+ store1.createIndex("email", "email", { unique: true });
+ dbResult.createObjectStore("obj2", { keyPath: "id2" });
+ store1.transaction.oncomplete = () => {
+ done(dbResult);
+ };
+ };
+ });
+
+ // Prevents AbortError
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ const transaction = db.transaction(["obj1", "obj2"], "readwrite");
+ const store1 = transaction.objectStore("obj1");
+ const store2 = transaction.objectStore("obj2");
+ store1.add({id: 1, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 2, name: "foo2", email: "foo2@bar.com"});
+ store1.add({id: 3, name: "foo2", email: "foo3@bar.com"});
+ store2.add({
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz"
+ });
+ // Prevents AbortError during close()
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb2", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const db2Result = event.target.result;
+ const store3 = db2Result.createObjectStore("obj3", { keyPath: "id3" });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(db2Result);
+ }
+ };
+ });
+ // Prevents AbortError during close()
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+ db2.close();
+
+ console.log("added cookies and stuff from main page");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.setup = async function () {
+ await idbGenerator();
+};
+
+window.clear = async function () {
+ document.cookie = "c1=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+ document.cookie = "c3=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+ document.cookie = "cs2=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+
+ localStorage.clear();
+
+ await deleteDB("idb1");
+ await deleteDB("idb2");
+
+ dump("removed cookies, localStorage and indexedDB data from " +
+ document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/storage-helpers.js b/devtools/server/tests/browser/storage-helpers.js
new file mode 100644
index 0000000000..1315c77b31
--- /dev/null
+++ b/devtools/server/tests/browser/storage-helpers.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This file assumes head.js is loaded in the global scope.
+/* import-globals-from head.js */
+
+/* exported openTabAndSetupStorage, clearStorage */
+
+"use strict";
+
+/**
+ * This generator function opens the given url in a new tab, then sets up the
+ * page by waiting for all cookies, indexedDB items etc. to be created.
+ *
+ * @param url {String} The url to be opened in the new tab
+ *
+ * @return {Promise} A promise that resolves after storage inspector is ready
+ */
+async function openTabAndSetupStorage(url) {
+ await addTab(url);
+
+ // Setup the async storages in main window and for all its iframes
+ const browsingContexts =
+ gBrowser.selectedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ for (const browsingContext of browsingContexts) {
+ await SpecialPowers.spawn(browsingContext, [], async function () {
+ if (content.wrappedJSObject.setup) {
+ await content.wrappedJSObject.setup();
+ }
+ });
+ }
+
+ // selected tab is set in addTab
+ const commands = await CommandsFactory.forTab(gBrowser.selectedTab);
+ await commands.targetCommand.startListening();
+ const target = commands.targetCommand.targetFront;
+ return { commands, target };
+}
+
+async function clearStorage() {
+ const browsingContexts =
+ gBrowser.selectedBrowser.browsingContext.getAllBrowsingContextsInSubtree();
+ for (const browsingContext of browsingContexts) {
+ await SpecialPowers.spawn(browsingContext, [], async function () {
+ if (content.wrappedJSObject.clear) {
+ await content.wrappedJSObject.clear();
+ }
+ });
+ }
+}
diff --git a/devtools/server/tests/browser/storage-listings.html b/devtools/server/tests/browser/storage-listings.html
new file mode 100644
index 0000000000..98ac182bd0
--- /dev/null
+++ b/devtools/server/tests/browser/storage-listings.html
@@ -0,0 +1,123 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 965872 - Storage inspector actor with cookies, local storage and session storage.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector test for listing hosts and storages</title>
+</head>
+<body>
+<iframe src="http://sectest1.example.org/browser/devtools/server/tests/browser/storage-unsecured-iframe.html"></iframe>
+<iframe src="https://sectest1.example.org:443/browser/devtools/server/tests/browser/storage-secured-iframe.html"></iframe>
+<script type="application/javascript">
+"use strict";
+const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1];
+const cookieExpiresTime1 = 2000000000000;
+const cookieExpiresTime2 = 2000000001000;
+// Setting up some cookies to eat.
+document.cookie = "c1=foobar; expires=" +
+ new Date(cookieExpiresTime1).toGMTString() + "; path=/browser";
+document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname;
+document.cookie = "c3=foobar-2; secure=true; expires=" +
+ new Date(cookieExpiresTime2).toGMTString() + "; path=/";
+// ... and some local storage items ..
+localStorage.setItem("ls1", "foobar");
+localStorage.setItem("ls2", "foobar-2");
+// ... and finally some session storage items too
+sessionStorage.setItem("ss1", "foobar-3");
+console.log("added cookies and stuff from main page");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const dbResult = event.target.result;
+ const store1 = dbResult.createObjectStore("obj1", { keyPath: "id" });
+ store1.createIndex("name", "name", { unique: false });
+ store1.createIndex("email", "email", { unique: true });
+ dbResult.createObjectStore("obj2", { keyPath: "id2" });
+ store1.transaction.oncomplete = () => {
+ done(dbResult);
+ };
+ };
+ });
+
+ // Prevents AbortError
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ const transaction = db.transaction(["obj1", "obj2"], "readwrite");
+ const store1 = transaction.objectStore("obj1");
+ const store2 = transaction.objectStore("obj2");
+ store1.add({id: 1, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 2, name: "foo2", email: "foo2@bar.com"});
+ store1.add({id: 3, name: "foo2", email: "foo3@bar.com"});
+ store2.add({
+ id2: 1,
+ name: "foo",
+ email: "foo@bar.com",
+ extra: "baz"
+ });
+ // Prevents AbortError during close()
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb2", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const db2Result = event.target.result;
+ const store3 = db2Result.createObjectStore("obj3", { keyPath: "id3" });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(db2Result);
+ }
+ };
+ });
+ // Prevents AbortError during close()
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+ db2.close();
+
+ dump("added cookies and stuff from main page\n");
+};
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.setup = async function () {
+ await idbGenerator();
+};
+
+window.clear = async function () {
+ document.cookie = "c1=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/browser";
+ document.cookie =
+ "c3=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; secure=true";
+ document.cookie =
+ "cs2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=" +
+ partialHostname;
+
+ localStorage.clear();
+ sessionStorage.clear();
+
+ await deleteDB("idb1");
+ await deleteDB("idb2");
+
+ dump("removed cookies, localStorage, sessionStorage and indexedDB data " +
+ "from " + document.location + "\n");
+};
+</script>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/storage-secured-iframe.html b/devtools/server/tests/browser/storage-secured-iframe.html
new file mode 100644
index 0000000000..c2fe4ed485
--- /dev/null
+++ b/devtools/server/tests/browser/storage-secured-iframe.html
@@ -0,0 +1,94 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Iframe for testing multiple host detetion in storage actor
+-->
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+document.cookie = "sc1=foobar;";
+localStorage.setItem("iframe-s-ls1", "foobar");
+sessionStorage.setItem("iframe-s-ss1", "foobar-2");
+
+const idbGenerator = async function () {
+ let request = indexedDB.open("idb-s1", 1);
+ request.onerror = function() {
+ throw new Error("error opening db connection");
+ };
+ const db = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const dbResult = event.target.result;
+ const store1 = dbResult.createObjectStore("obj-s1", { keyPath: "id" });
+ store1.transaction.oncomplete = () => {
+ done(dbResult);
+ };
+ };
+ });
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ let transaction = db.transaction(["obj-s1"], "readwrite");
+ const store1 = transaction.objectStore("obj-s1");
+ store1.add({id: 6, name: "foo", email: "foo@bar.com"});
+ store1.add({id: 7, name: "foo2", email: "foo2@bar.com"});
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db.close();
+
+ request = indexedDB.open("idb-s2", 1);
+ const db2 = await new Promise(done => {
+ request.onupgradeneeded = event => {
+ const db2Result = event.target.result;
+ const store3 =
+ db2Result.createObjectStore("obj-s2", { keyPath: "id3", autoIncrement: true });
+ store3.createIndex("name2", "name2", { unique: true });
+ store3.transaction.oncomplete = () => {
+ done(db2Result);
+ };
+ };
+ });
+ await new Promise(done => {
+ request.onsuccess = done;
+ });
+
+ transaction = db2.transaction(["obj-s2"], "readwrite");
+ const store3 = transaction.objectStore("obj-s2");
+ store3.add({id3: 16, name2: "foo", email: "foo@bar.com"});
+ await new Promise(success => {
+ transaction.oncomplete = success;
+ });
+
+ db2.close();
+ dump("added cookies and stuff from secured iframe\n");
+}
+
+function deleteDB(dbName) {
+ return new Promise(resolve => {
+ dump("removing database " + dbName + " from " + document.location + "\n");
+ indexedDB.deleteDatabase(dbName).onsuccess = resolve;
+ });
+}
+
+window.setup = async function () {
+ await idbGenerator();
+};
+
+window.clear = async function () {
+ document.cookie = "sc1=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+
+ localStorage.clear();
+
+ await deleteDB("idb-s1");
+ await deleteDB("idb-s2");
+
+ console.log("removed cookies and stuff from secured iframe");
+}
+</script>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/storage-unsecured-iframe.html b/devtools/server/tests/browser/storage-unsecured-iframe.html
new file mode 100644
index 0000000000..db70c9c692
--- /dev/null
+++ b/devtools/server/tests/browser/storage-unsecured-iframe.html
@@ -0,0 +1,28 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Iframe for testing multiple host detetion in storage actor
+-->
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script>
+"use strict";
+
+document.cookie = "uc1=foobar; domain=.example.org; path=/; secure=true";
+localStorage.setItem("iframe-u-ls1", "foobar");
+sessionStorage.setItem("iframe-u-ss1", "foobar1");
+sessionStorage.setItem("iframe-u-ss2", "foobar2");
+console.log("added cookies and stuff from unsecured iframe");
+
+window.clear = function () {
+ document.cookie = "uc1=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+ localStorage.clear();
+ sessionStorage.clear();
+ console.log("removed cookies and stuff from unsecured iframe");
+};
+
+</script>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/storage-updates.html b/devtools/server/tests/browser/storage-updates.html
new file mode 100644
index 0000000000..594c28ce0f
--- /dev/null
+++ b/devtools/server/tests/browser/storage-updates.html
@@ -0,0 +1,47 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 965872 - Storage inspector actor with cookies, local storage and session storage.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Storage inspector blank html for tests</title>
+</head>
+<body>
+<script type="application/javascript">
+"use strict";
+window.addCookie = function(name, value, path, domain, expires, secure) {
+ let cookieString = name + "=" + value + ";";
+ if (path) {
+ cookieString += "path=" + path + ";";
+ }
+ if (domain) {
+ cookieString += "domain=" + domain + ";";
+ }
+ if (expires) {
+ cookieString += "expires=" + expires + ";";
+ }
+ if (secure) {
+ cookieString += "secure=true;";
+ }
+ document.cookie = cookieString;
+};
+
+window.removeCookie = function(name) {
+ document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
+};
+
+window.clearLocalAndSessionStores = function() {
+ localStorage.clear();
+ sessionStorage.clear();
+};
+
+window.clearCookies = function() {
+ const cookies = document.cookie;
+ for (const cookie of cookies.split(";")) {
+ window.removeCookie(cookie.split("=")[0]);
+ }
+};
+</script>
+</body>
+</html>
diff --git a/devtools/server/tests/browser/test-errors-actor.js b/devtools/server/tests/browser/test-errors-actor.js
new file mode 100644
index 0000000000..e476324be4
--- /dev/null
+++ b/devtools/server/tests/browser/test-errors-actor.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const protocol = require("resource://devtools/shared/protocol.js");
+
+const testErrorsSpec = protocol.generateActorSpec({
+ typeName: "testErrors",
+
+ methods: {
+ throwsComponentsException: {
+ request: {},
+ response: {},
+ },
+ throwsException: {
+ request: {},
+ response: {},
+ },
+ throwsJSError: {
+ request: {},
+ response: {},
+ },
+ throwsString: {
+ request: {},
+ response: {},
+ },
+ throwsObject: {
+ request: {},
+ response: {},
+ },
+ },
+});
+
+class TestErrorsActor extends protocol.Actor {
+ constructor(conn) {
+ super(conn, testErrorsSpec);
+ }
+
+ throwsComponentsException() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ throwsException() {
+ return this.a.b.c;
+ }
+
+ throwsJSError() {
+ throw new Error("JSError");
+ }
+
+ throwsString() {
+ // eslint-disable-next-line no-throw-literal
+ throw "ErrorString";
+ }
+
+ throwsObject() {
+ // eslint-disable-next-line no-throw-literal
+ throw {
+ error: "foo",
+ };
+ }
+}
+exports.TestErrorsActor = TestErrorsActor;
+
+class TestErrorsFront extends protocol.FrontClassWithSpec(testErrorsSpec) {
+ constructor(client) {
+ super(client);
+ this.formAttributeName = "testErrorsActor";
+ }
+}
+protocol.registerFront(TestErrorsFront);
diff --git a/devtools/server/tests/browser/test-window.xhtml b/devtools/server/tests/browser/test-window.xhtml
new file mode 100644
index 0000000000..33e70e2dee
--- /dev/null
+++ b/devtools/server/tests/browser/test-window.xhtml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xul:window xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ title="Test page">
+</xul:window>
diff --git a/devtools/server/tests/chrome/Debugger.Source.prototype.element-2.js b/devtools/server/tests/chrome/Debugger.Source.prototype.element-2.js
new file mode 100644
index 0000000000..7260431428
--- /dev/null
+++ b/devtools/server/tests/chrome/Debugger.Source.prototype.element-2.js
@@ -0,0 +1,4 @@
+"use strict";
+
+// eslint-disable-next-line no-debugger
+debugger;
diff --git a/devtools/server/tests/chrome/Debugger.Source.prototype.element.html b/devtools/server/tests/chrome/Debugger.Source.prototype.element.html
new file mode 100644
index 0000000000..6959ad970d
--- /dev/null
+++ b/devtools/server/tests/chrome/Debugger.Source.prototype.element.html
@@ -0,0 +1,25 @@
+<head>
+ <!-- Static (not dynamically inserted) inline script. -->
+ <script id='franz'>
+ /* exported franz */
+ "use strict";
+
+ function franz() {
+ // eslint-disable-next-line no-debugger
+ debugger;
+ }
+ </script>
+
+ <!-- Static out-of-line script element. -->
+ <script id='heinrich' src='Debugger.Source.prototype.element.js'></script>
+</head>
+
+<!-- HTML requires some body element onfoo attributes to add handlers to the
+ *window*, not the element --- but Debugger.Source.prototype.element should
+ return the element. Here, that rule should apply to the body's 'onresize'
+ handler. (For the reason for the 'cancelable' check, see the code that
+ sends the event.) -->
+<body onresize='if (event.cancelable) debugger;'>
+ <!-- Ordinary content element with event handler. -->
+ <div id='heidi' onclick='heinrichFun();'>Heidi</div>
+</body>
diff --git a/devtools/server/tests/chrome/Debugger.Source.prototype.element.js b/devtools/server/tests/chrome/Debugger.Source.prototype.element.js
new file mode 100644
index 0000000000..095398ddad
--- /dev/null
+++ b/devtools/server/tests/chrome/Debugger.Source.prototype.element.js
@@ -0,0 +1,7 @@
+/* exported heinrichFun */
+/* global franz */
+"use strict";
+
+function heinrichFun() {
+ franz();
+}
diff --git a/devtools/server/tests/chrome/chrome.toml b/devtools/server/tests/chrome/chrome.toml
new file mode 100644
index 0000000000..b67b1ee971
--- /dev/null
+++ b/devtools/server/tests/chrome/chrome.toml
@@ -0,0 +1,150 @@
+[DEFAULT]
+tags = "devtools"
+skip-if = ["os == 'android'"]
+support-files = [
+ "doc_Debugger.Source.prototype.introductionType.xhtml",
+ "Debugger.Source.prototype.element.js",
+ "Debugger.Source.prototype.element-2.js",
+ "Debugger.Source.prototype.element.html",
+ "hello-actor.js",
+ "iframe1_makeGlobalObjectReference.html",
+ "iframe2_makeGlobalObjectReference.html",
+ "inspector_css-properties.html",
+ "inspector_display-type.html",
+ "inspector_getImageData.html",
+ "inspector_getOffsetParent.html",
+ "inspector-delay-image-response.sjs",
+ "inspector-eyedropper.html",
+ "inspector-helpers.js",
+ "inspector-search-data.html",
+ "inspector-styles-data.css",
+ "inspector-styles-data.html",
+ "inspector-template.html",
+ "inspector-traversal-data.html",
+ "large-image.jpg",
+ "memory-helpers.js",
+ "nonchrome_unsafeDereference.html",
+ "suspendTimeouts_content.html",
+ "suspendTimeouts_content.js",
+ "suspendTimeouts_worker.js",
+ "small-image.gif",
+ "test_suspendTimeouts.js",
+ "webconsole-helpers.js",
+ "inactive-property-helper/*.mjs",
+]
+
+["test_Debugger.Script.prototype.global.html"]
+
+["test_Debugger.Source.prototype.elementAttribute.html"]
+
+["test_Debugger.Source.prototype.introductionScript.html"]
+
+["test_Debugger.Source.prototype.introductionType.html"]
+
+["test_animation-type-longhand.html"]
+
+["test_css-logic-specificity.html"]
+
+["test_css-logic.html"]
+
+["test_css-properties.html"]
+
+["test_device.html"]
+
+["test_executeInGlobal-outerized_this.html"]
+
+["test_highlighter_paused_debugger.html"]
+
+["test_inspector-changeattrs.html"]
+
+["test_inspector-changevalue.html"]
+
+["test_inspector-display-type.html"]
+
+["test_inspector-duplicate-node.html"]
+
+["test_inspector-hide.html"]
+
+["test_inspector-inactive-property-helper.html"]
+
+["test_inspector-mutations-attr.html"]
+
+["test_inspector-mutations-events.html"]
+
+["test_inspector-mutations-value.html"]
+
+["test_inspector-pick-color.html"]
+
+["test_inspector-pseudoclass-lock.html"]
+
+["test_inspector-reload.html"]
+
+["test_inspector-resize.html"]
+
+["test_inspector-resolve-url.html"]
+
+["test_inspector-scroll-into-view.html"]
+
+["test_inspector-search-front.html"]
+
+["test_inspector-template.html"]
+
+["test_inspector_getImageData-wait-for-load.html"]
+
+["test_inspector_getImageData.html"]
+
+["test_inspector_getImageDataFromURL.html"]
+
+["test_inspector_getNodeFromActor.html"]
+
+["test_inspector_getOffsetParent.html"]
+
+["test_makeGlobalObjectReference.html"]
+
+["test_memory.html"]
+
+["test_memory_allocations_02.html"]
+
+["test_memory_allocations_03.html"]
+
+["test_memory_allocations_04.html"]
+
+["test_memory_allocations_05.html"]
+
+["test_memory_allocations_06.html"]
+
+["test_memory_allocations_07.html"]
+
+["test_memory_attach_01.html"]
+
+["test_memory_attach_02.html"]
+
+["test_memory_census.html"]
+
+["test_memory_gc_01.html"]
+
+["test_memory_gc_events.html"]
+
+["test_overflowing-body.html"]
+
+["test_overflowing-children.html"]
+
+["test_preference.html"]
+
+["test_styles-applied.html"]
+
+["test_styles-computed.html"]
+
+["test_styles-layout.html"]
+
+["test_styles-matched.html"]
+
+["test_styles-modify.html"]
+
+["test_styles-svg.html"]
+
+["test_suspendTimeouts.html"]
+
+["test_unsafeDereference.html"]
+
+["test_webconsole-node-grip.html"]
diff --git a/devtools/server/tests/chrome/doc_Debugger.Source.prototype.introductionType.xhtml b/devtools/server/tests/chrome/doc_Debugger.Source.prototype.introductionType.xhtml
new file mode 100644
index 0000000000..b037190c9a
--- /dev/null
+++ b/devtools/server/tests/chrome/doc_Debugger.Source.prototype.introductionType.xhtml
@@ -0,0 +1,7 @@
+<?xml version="1.0"?>
+<window xmlns='http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'>
+<script id='xulie'>
+/* eslint-disable strict, no-unused-vars, no-debugger */
+function xulScriptFunc() { debugger; }
+</script>
+</window>
diff --git a/devtools/server/tests/chrome/hello-actor.js b/devtools/server/tests/chrome/hello-actor.js
new file mode 100644
index 0000000000..eabb4a6773
--- /dev/null
+++ b/devtools/server/tests/chrome/hello-actor.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* exported HelloActor */
+"use strict";
+
+const protocol = require("resource://devtools/shared/protocol.js");
+
+const helloSpec = protocol.generateActorSpec({
+ typeName: "helloActor",
+
+ methods: {
+ count: {
+ request: {},
+ response: { count: protocol.RetVal("number") },
+ },
+ },
+});
+
+class HelloActor extends protocol.Actor {
+ constructor(conn) {
+ super(conn, helloSpec);
+ this.counter = 0;
+ }
+
+ count() {
+ return ++this.counter;
+ }
+}
diff --git a/devtools/server/tests/chrome/iframe1_makeGlobalObjectReference.html b/devtools/server/tests/chrome/iframe1_makeGlobalObjectReference.html
new file mode 100644
index 0000000000..bab5a70765
--- /dev/null
+++ b/devtools/server/tests/chrome/iframe1_makeGlobalObjectReference.html
@@ -0,0 +1 @@
+<html>The word 'smorgasbord' spoken by an adorably plump child, symbolizing prosperity</html>
diff --git a/devtools/server/tests/chrome/iframe2_makeGlobalObjectReference.html b/devtools/server/tests/chrome/iframe2_makeGlobalObjectReference.html
new file mode 100644
index 0000000000..b297ca8a2b
--- /dev/null
+++ b/devtools/server/tests/chrome/iframe2_makeGlobalObjectReference.html
@@ -0,0 +1 @@
+<html>Her retrospection, in hindsight, was prescient.</html>
diff --git a/devtools/server/tests/chrome/inactive-property-helper/align-content.mjs b/devtools/server/tests/chrome/inactive-property-helper/align-content.mjs
new file mode 100644
index 0000000000..a871081fad
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/align-content.mjs
@@ -0,0 +1,92 @@
+/* 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/. */
+
+// InactivePropertyHelper `align-content` test cases.
+
+export default [
+ {
+ info: "align-content is inactive on block elements (until bug 1105571 is fixed)",
+ property: "align-content",
+ tagName: "div",
+ rules: ["div { align-content: center; }"],
+ isActive: false,
+ },
+ {
+ info: "align-content is active on flex containers",
+ property: "align-content",
+ tagName: "div",
+ rules: ["div { align-content: center; display: flex; }"],
+ isActive: true,
+ },
+ {
+ info: "align-content is active on grid containers",
+ property: "align-content",
+ tagName: "div",
+ rules: ["div { align-content: center; display: grid; }"],
+ isActive: true,
+ },
+ {
+ info: "align-content is inactive on flex items",
+ property: "align-content",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: flex; }", "span { align-content: center; }"],
+ ruleIndex: 1,
+ isActive: false,
+ },
+ {
+ info: "align-content is inactive on grid items",
+ property: "align-content",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: grid; }", "span { align-content: center; }"],
+ ruleIndex: 1,
+ isActive: false,
+ },
+ {
+ info: "align-content:baseline is active on flex items",
+ property: "align-content",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: flex; }", "span { align-content: baseline; }"],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: "align-content:baseline is active on grid items",
+ property: "align-content",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: grid; }", "span { align-content: baseline; }"],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: "align-content:baseline is active on table cells",
+ property: "align-content",
+ tagName: "div",
+ rules: ["div { display: table-cell; align-content: baseline; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/border-image.mjs b/devtools/server/tests/chrome/inactive-property-helper/border-image.mjs
new file mode 100644
index 0000000000..85c57418a4
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/border-image.mjs
@@ -0,0 +1,162 @@
+/* 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/. */
+
+// InactivePropertyHelper `border-image` test cases.
+export default [
+ {
+ info: "border-image is active on another element then a table element or internal table element where border-collapse is not set to collapse",
+ property: "border-image",
+ tagName: "div",
+ rules: ["div { border-image: linear-gradient(red, yellow) 10; }"],
+ isActive: true,
+ },
+ {
+ info: "border-image is active on another element then a table element or internal table element where border-collapse is set to collapse",
+ property: "border-image",
+ tagName: "div",
+ rules: [
+ "div { border-image: linear-gradient(red, yellow) 10; border-collapse: collapse;}",
+ ],
+ isActive: true,
+ },
+ {
+ info: "border-image is active on a td element with no table parent and the browser is not crashing",
+ property: "border-image",
+ tagName: "td",
+ rules: [
+ "td { border-image: linear-gradient(red, yellow) 10; border-collapse: collapse;}",
+ ],
+ isActive: true,
+ },
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: false,
+ borderCollapse: true,
+ borderCollapsePropertyIsInherited: false,
+ isActive: true,
+ }),
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: false,
+ borderCollapse: false,
+ borderCollapsePropertyIsInherited: false,
+ isActive: true,
+ }),
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: false,
+ borderCollapse: true,
+ borderCollapsePropertyIsInherited: true,
+ isActive: false,
+ }),
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: false,
+ borderCollapse: false,
+ borderCollapsePropertyIsInherited: true,
+ isActive: true,
+ }),
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: true,
+ borderCollapse: true,
+ borderCollapsePropertyIsInherited: false,
+ isActive: true,
+ }),
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: true,
+ borderCollapse: false,
+ borderCollapsePropertyIsInherited: false,
+ isActive: true,
+ }),
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: true,
+ borderCollapse: true,
+ borderCollapsePropertyIsInherited: true,
+ isActive: false,
+ }),
+ createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle: true,
+ borderCollapse: false,
+ borderCollapsePropertyIsInherited: true,
+ isActive: true,
+ }),
+];
+
+/**
+ * @param {Object} testParameters
+ * @param {bool} testParameters.useDivTagWithDisplayTableStyle use generic divs using display property instead of actual table/tr/td tags
+ * @param {bool} testParameters.borderCollapse is `border-collapse` property set to `collapse` ( instead of `separate`)
+ * @param {bool} testParameters.borderCollapsePropertyIsInherited should the border collapse property be inherited from the table parent (instead of directly set on the internal table element)
+ * @param {bool} testParameters.isActive is the border-image property actve on the element
+ * @returns
+ */
+function createTableElementsToTestBorderImage({
+ useDivTagWithDisplayTableStyle,
+ borderCollapse,
+ borderCollapsePropertyIsInherited,
+ isActive,
+}) {
+ return {
+ info: `border-image is ${
+ isActive ? "active" : "inactive"
+ } on an internal table element where border-collapse is${
+ borderCollapse ? "" : " not"
+ } set to collapse${
+ borderCollapsePropertyIsInherited
+ ? " by being inherited from its table parent"
+ : ""
+ } when the table and its internal elements are ${
+ useDivTagWithDisplayTableStyle ? "not " : ""
+ }using semantic tags (table, tr, td, ...)`,
+ property: "border-image",
+ createTestElement: rootNode => {
+ const table = useDivTagWithDisplayTableStyle
+ ? document.createElement("div")
+ : document.createElement("table");
+ if (useDivTagWithDisplayTableStyle) {
+ table.style.display = "table";
+ }
+ if (borderCollapsePropertyIsInherited) {
+ table.style.borderCollapse = `${
+ borderCollapse ? "collapse" : "separate"
+ }`;
+ }
+ rootNode.appendChild(table);
+
+ const tbody = useDivTagWithDisplayTableStyle
+ ? document.createElement("div")
+ : document.createElement("tbody");
+ if (useDivTagWithDisplayTableStyle) {
+ tbody.style.display = "table-row-group";
+ }
+ table.appendChild(tbody);
+
+ const tr = useDivTagWithDisplayTableStyle
+ ? document.createElement("div")
+ : document.createElement("tr");
+ if (useDivTagWithDisplayTableStyle) {
+ tr.style.display = "table-row";
+ }
+ tbody.appendChild(tr);
+
+ const td = useDivTagWithDisplayTableStyle
+ ? document.createElement("div")
+ : document.createElement("td");
+ if (useDivTagWithDisplayTableStyle) {
+ td.style.display = "table-cell";
+ td.classList.add("td");
+ }
+ tr.appendChild(td);
+
+ return td;
+ },
+ rules: [
+ `td, .td {
+ border-image: linear-gradient(red, yellow) 10;
+ ${
+ !borderCollapsePropertyIsInherited
+ ? `border-collapse: ${borderCollapse ? "collapse" : "separate"};`
+ : ""
+ }
+ }`,
+ ],
+ isActive,
+ };
+}
diff --git a/devtools/server/tests/chrome/inactive-property-helper/cue-pseudo-element.mjs b/devtools/server/tests/chrome/inactive-property-helper/cue-pseudo-element.mjs
new file mode 100644
index 0000000000..7a55425632
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/cue-pseudo-element.mjs
@@ -0,0 +1,371 @@
+/* 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/. */
+
+// InactivePropertyHelper `cue-pseudo-element` test cases.
+
+// "background",
+// "background-attachment",
+// "background-blend-mode",
+// "background-clip",
+// "background-color",
+// "background-image",
+// "background-origin",
+// "background-position",
+// "background-position-x",
+// "background-position-y",
+// "background-repeat",
+// "background-size",
+// "color",
+// "font",
+// "font-family",
+// "font-size",
+// "font-stretch",
+// "font-style",
+// "font-variant",
+// "font-variant-alternates",
+// "font-variant-caps",
+// "font-variant-east-asian",
+// "font-variant-ligatures",
+// "font-variant-numeric",
+// "font-variant-position",
+// "font-weight",
+// "line-height",
+// "opacity",
+// "outline",
+// "outline-color",
+// "outline-offset",
+// "outline-style",
+// "outline-width",
+// "ruby-position",
+// "text-combine-upright",
+// "text-decoration",
+// "text-decoration-color",
+// "text-decoration-line",
+// "text-decoration-style",
+// "text-decoration-thickness",
+// "text-shadow",
+// "visibility",
+// "white-space",
+
+export default [
+ {
+ info: "background is active on ::cue",
+ property: "background",
+ tagName: "video",
+ rules: ["video::cue { background: linear-gradient(white, black); }"],
+ isActive: true,
+ },
+ {
+ info: "background-attachment is active on ::cue",
+ property: "background-attachment",
+ tagName: "video",
+ rules: ["video::cue { background-attachment: fixed; }"],
+ isActive: true,
+ },
+ {
+ info: "background-blend-mode is active on ::cue",
+ property: "background-blend-mode",
+ tagName: "video",
+ rules: ["video::cue { background-blend-mode: difference; }"],
+ isActive: true,
+ },
+ {
+ info: "background-clip is active on ::cue",
+ property: "background-clip",
+ tagName: "video",
+ rules: ["video::cue { background-clip: padding-box; }"],
+ isActive: true,
+ },
+ {
+ info: "background-color is active on ::cue",
+ property: "background-color",
+ tagName: "video",
+ rules: ["video::cue { background-color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "background-image is active on ::cue",
+ property: "background-image",
+ tagName: "video",
+ rules: [
+ "video::cue { background-image: url('https://example.com/image.png'); }",
+ ],
+ isActive: true,
+ },
+ {
+ info: "background-origin is active on ::cue",
+ property: "background-origin",
+ tagName: "video",
+ rules: ["video::cue { background-origin: padding-box; }"],
+ isActive: true,
+ },
+ {
+ info: "background-position is active on ::cue",
+ property: "background-position",
+ tagName: "video",
+ rules: ["video::cue { background-position: 0 0; }"],
+ isActive: true,
+ },
+ {
+ info: "background-position-x is active on ::cue",
+ property: "background-position-x",
+ tagName: "video",
+ rules: ["video::cue { background-position-x: 0; }"],
+ isActive: true,
+ },
+ {
+ info: "background-position-y is active on ::cue",
+ property: "background-position-y",
+ tagName: "video",
+ rules: ["video::cue { background-position-y: 0; }"],
+ isActive: true,
+ },
+ {
+ info: "background-repeat is active on ::cue",
+ property: "background-repeat",
+ tagName: "video",
+ rules: ["video::cue { background-repeat: repeat-y; }"],
+ isActive: true,
+ },
+ {
+ info: "background-size is active on ::cue",
+ property: "background-size",
+ tagName: "video",
+ rules: ["video::cue { background-size: 100% 100%; }"],
+ isActive: true,
+ },
+ {
+ info: "color is active on ::cue",
+ property: "color",
+ tagName: "video",
+ rules: ["video::cue { color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "font is active on ::cue",
+ property: "font",
+ tagName: "video",
+ rules: ["video::cue { font: 1em sans-serif; }"],
+ isActive: true,
+ },
+ {
+ info: "font-family is active on ::cue",
+ property: "font-family",
+ tagName: "video",
+ rules: ["video::cue { font-family: sans-serif; }"],
+ isActive: true,
+ },
+ {
+ info: "font-size is active on ::cue",
+ property: "font-size",
+ tagName: "video",
+ rules: ["video::cue { font-size: 1em; }"],
+ isActive: true,
+ },
+ {
+ info: "font-stretch is active on ::cue",
+ property: "font-stretch",
+ tagName: "video",
+ rules: ["video::cue { font-stretch: ultra-condensed; }"],
+ isActive: true,
+ },
+ {
+ info: "font-style is active on ::cue",
+ property: "font-style",
+ tagName: "video",
+ rules: ["video::cue { font-style: italic; }"],
+ isActive: true,
+ },
+ {
+ info: "font-variant is active on ::cue",
+ property: "font-variant",
+ tagName: "video",
+ rules: ["video::cue { font-variant: small-caps; }"],
+ isActive: true,
+ },
+ {
+ info: "font-variant-alternates is active on ::cue",
+ property: "font-variant-alternates",
+ tagName: "video",
+ rules: ["video::cue { font-variant-alternates: slashed-zero; }"],
+ isActive: true,
+ },
+ {
+ info: "font-variant-caps is active on ::cue",
+ property: "font-variant-caps",
+ tagName: "video",
+ rules: ["video::cue { font-variant-caps: all-small-caps; }"],
+ isActive: true,
+ },
+ {
+ info: "font-variant-east-asian is active on ::cue",
+ property: "font-variant-east-asian",
+ tagName: "video",
+ rules: ["video::cue { font-variant-east-asian: ruby; }"],
+ isActive: true,
+ },
+ {
+ info: "font-variant-ligatures is active on ::cue",
+ property: "font-variant-ligatures",
+ tagName: "video",
+ rules: ["video::cue { font-variant-ligatures: common-ligatures; }"],
+ isActive: true,
+ },
+ {
+ info: "font-variant-numeric is active on ::cue",
+ property: "font-variant-numeric",
+ tagName: "video",
+ rules: ["video::cue { font-variant-numeric: ordinal; }"],
+ isActive: true,
+ },
+ {
+ info: "font-variant-position is active on ::cue",
+ property: "font-variant-position",
+ tagName: "video",
+ rules: ["video::cue { font-variant-position: sub; }"],
+ isActive: true,
+ },
+ {
+ info: "font-weight is active on ::cue",
+ property: "font-weight",
+ tagName: "video",
+ rules: ["video::cue { font-weight: bold; }"],
+ isActive: true,
+ },
+ {
+ info: "line-height is active on ::cue",
+ property: "line-height",
+ tagName: "video",
+ rules: ["video::cue { line-height: 1.2; }"],
+ isActive: true,
+ },
+ {
+ info: "opacity is active on ::cue",
+ property: "opacity",
+ tagName: "video",
+ rules: ["video::cue { opacity: 0.8; }"],
+ isActive: true,
+ },
+ {
+ info: "outline is active on ::cue",
+ property: "outline",
+ tagName: "video",
+ rules: ["video::cue { outline: 1px solid red; }"],
+ isActive: true,
+ },
+ {
+ info: "outline-color is active on ::cue",
+ property: "outline-color",
+ tagName: "video",
+ rules: ["video::cue { outline-color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "outline-offset is active on ::cue",
+ property: "outline-offset",
+ tagName: "video",
+ rules: ["video::cue { outline-offset: 1px; }"],
+ isActive: true,
+ },
+ {
+ info: "outline-style is active on ::cue",
+ property: "outline-style",
+ tagName: "video",
+ rules: ["video::cue { outline-style: solid; }"],
+ isActive: true,
+ },
+ {
+ info: "outline-width is active on ::cue",
+ property: "outline-width",
+ tagName: "video",
+ rules: ["video::cue { outline-width: 1px; }"],
+ isActive: true,
+ },
+ {
+ info: "ruby-position is active on ::cue",
+ property: "ruby-position",
+ tagName: "video",
+ rules: ["video::cue { ruby-position: over; }"],
+ isActive: true,
+ },
+ {
+ info: "text-combine-upright is active on ::cue",
+ property: "text-combine-upright",
+ tagName: "video",
+ rules: ["video::cue { text-combine-upright: all; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration is active on ::cue",
+ property: "text-decoration",
+ tagName: "video",
+ rules: ["video::cue { text-decoration: 1px underline red; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-color is active on ::cue",
+ property: "text-decoration-color",
+ tagName: "video",
+ rules: ["video::cue { text-decoration-color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-line is active on ::cue",
+ property: "text-decoration-line",
+ tagName: "video",
+ rules: ["video::cue { text-decoration-line: underline; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-style is active on ::cue",
+ property: "text-decoration-style",
+ tagName: "video",
+ rules: ["video::cue { text-decoration-style: wavy; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-thickness is active on ::cue",
+ property: "text-decoration-thickness",
+ tagName: "video",
+ rules: ["video::cue { text-decoration-thickness: 1px; }"],
+ isActive: true,
+ },
+ {
+ info: "text-shadow is active on ::cue",
+ property: "text-shadow",
+ tagName: "video",
+ rules: ["video::cue { text-shadow: 1px 1px 1px red; }"],
+ isActive: true,
+ },
+ {
+ info: "visibility is active on ::cue",
+ property: "visibility",
+ tagName: "video",
+ rules: ["video::cue { visibility: hidden; }"],
+ isActive: true,
+ },
+ {
+ info: "white-space is active on ::cue",
+ property: "white-space",
+ tagName: "video",
+ rules: ["video::cue { white-space: nowrap; }"],
+ isActive: true,
+ },
+ {
+ info: "border is inactive on ::cue",
+ property: "border",
+ tagName: "video",
+ rules: ["video::cue { border: 1px solid red; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-cue-pseudo-element-not-supported",
+ },
+ {
+ info: "display is inactive on ::cue",
+ property: "display",
+ tagName: "video",
+ rules: ["video::cue { display: grid; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-cue-pseudo-element-not-supported",
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/first-letter-pseudo-element.mjs b/devtools/server/tests/chrome/inactive-property-helper/first-letter-pseudo-element.mjs
new file mode 100644
index 0000000000..ebce0d292a
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/first-letter-pseudo-element.mjs
@@ -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/. */
+
+// InactivePropertyHelper `first-letter-pseudo-element` test cases.
+
+// "content",
+
+export default [
+ {
+ info: "content is inactive on ::first-letter",
+ property: "content",
+ tagName: "div",
+ rules: ["div::first-letter { content: 'invalid'; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-first-letter-pseudo-element-not-supported",
+ },
+ {
+ info: "color is active on ::first-letter",
+ property: "color",
+ tagName: "div",
+ rules: ["div::first-letter { color: green; }"],
+ isActive: true,
+ },
+ {
+ info: "display is active on ::first-letter",
+ property: "display",
+ tagName: "div",
+ rules: ["div::first-letter { display: grid; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/first-line-pseudo-element.mjs b/devtools/server/tests/chrome/inactive-property-helper/first-line-pseudo-element.mjs
new file mode 100644
index 0000000000..68948a16bc
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/first-line-pseudo-element.mjs
@@ -0,0 +1,50 @@
+/* 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/. */
+
+// InactivePropertyHelper `first-line-pseudo-element` test cases.
+
+// "direction",
+// "text-orientation",
+// "writing-mode",
+
+export default [
+ {
+ info: "direction is inactive on ::first-line",
+ property: "direction",
+ tagName: "div",
+ rules: ["div::first-line { direction: rtl; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-first-line-pseudo-element-not-supported",
+ },
+ {
+ info: "text-orientation is inactive on ::first-line",
+ property: "text-orientation",
+ tagName: "div",
+ rules: ["div::first-line { text-orientation: sideways; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-first-line-pseudo-element-not-supported",
+ },
+ {
+ info: "writing-mode is inactive on ::first-line",
+ property: "writing-mode",
+ tagName: "div",
+ rules: ["div::first-line { writing-mode: vertical-rl; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-first-line-pseudo-element-not-supported",
+ },
+ {
+ info: "color is active on ::first-line",
+ property: "color",
+ tagName: "div",
+ rules: ["div::first-line { color: green; }"],
+ isActive: true,
+ },
+ {
+ info: "display is active on ::first-line",
+ property: "display",
+ tagName: "div",
+ rules: ["div::first-line { display: grid; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/flex-grid-item-properties.mjs b/devtools/server/tests/chrome/inactive-property-helper/flex-grid-item-properties.mjs
new file mode 100644
index 0000000000..79c587798a
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/flex-grid-item-properties.mjs
@@ -0,0 +1,229 @@
+/* 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/. */
+
+// InactivePropertyHelper `align-self`, `place-self`, and `order` test cases.
+export default [
+ {
+ info: "align-self is inactive on block element",
+ property: "align-self",
+ tagName: "div",
+ rules: ["div { align-self: center; }"],
+ isActive: false,
+ },
+ {
+ info: "align-self is inactive on flex container",
+ property: "align-self",
+ tagName: "div",
+ rules: ["div { align-self: center; display: flex;}"],
+ isActive: false,
+ },
+ {
+ info: "align-self is inactive on inline-flex container",
+ property: "align-self",
+ tagName: "div",
+ rules: ["div { align-self: center; display: inline-flex;}"],
+ isActive: false,
+ },
+ {
+ info: "align-self is inactive on grid container",
+ property: "align-self",
+ tagName: "div",
+ rules: ["div { align-self: center; display: grid;}"],
+ isActive: false,
+ },
+ {
+ info: "align-self is inactive on inline grid container",
+ property: "align-self",
+ tagName: "div",
+ rules: ["div { align-self: center; display: inline-grid;}"],
+ isActive: false,
+ },
+ {
+ info: "align-self is inactive on inline element",
+ property: "align-self",
+ tagName: "span",
+ rules: ["span { align-self: center; }"],
+ isActive: false,
+ },
+ {
+ info: "align-self is active on flex item",
+ property: "align-self",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: flex; align-items: start; }",
+ "span { align-self: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: "align-self is active on grid item",
+ property: "align-self",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: grid; align-items: start; }",
+ "span { align-self: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: "place-self is inactive on block element",
+ property: "place-self",
+ tagName: "div",
+ rules: ["div { place-self: center; }"],
+ isActive: false,
+ },
+ {
+ info: "place-self is inactive on flex container",
+ property: "place-self",
+ tagName: "div",
+ rules: ["div { place-self: center; display: flex;}"],
+ isActive: false,
+ },
+ {
+ info: "place-self is inactive on inline-flex container",
+ property: "place-self",
+ tagName: "div",
+ rules: ["div { place-self: center; display: inline-flex;}"],
+ isActive: false,
+ },
+ {
+ info: "place-self is inactive on grid container",
+ property: "place-self",
+ tagName: "div",
+ rules: ["div { place-self: center; display: grid;}"],
+ isActive: false,
+ },
+ {
+ info: "place-self is inactive on inline grid container",
+ property: "place-self",
+ tagName: "div",
+ rules: ["div { place-self: center; display: inline-grid;}"],
+ isActive: false,
+ },
+ {
+ info: "place-self is inactive on inline element",
+ property: "place-self",
+ tagName: "span",
+ rules: ["span { place-self: center; }"],
+ isActive: false,
+ },
+ {
+ info: "place-self is active on flex item",
+ property: "place-self",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: flex; align-items: start; }",
+ "span { place-self: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: "place-self is active on grid item",
+ property: "place-self",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: grid; align-items: start; }",
+ "span { place-self: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: "order is inactive on block element",
+ property: "order",
+ tagName: "div",
+ rules: ["div { order: 1; }"],
+ isActive: false,
+ },
+ {
+ info: "order is inactive on flex container",
+ property: "order",
+ tagName: "div",
+ rules: ["div { order: 1; display: flex;}"],
+ isActive: false,
+ },
+ {
+ info: "order is inactive on inline-flex container",
+ property: "order",
+ tagName: "div",
+ rules: ["div { order: 1; display: inline-flex;}"],
+ isActive: false,
+ },
+ {
+ info: "order is inactive on grid container",
+ property: "order",
+ tagName: "div",
+ rules: ["div { order: 1; display: grid;}"],
+ isActive: false,
+ },
+ {
+ info: "order is inactive on inline grid container",
+ property: "order",
+ tagName: "div",
+ rules: ["div { order: 1; display: inline-grid;}"],
+ isActive: false,
+ },
+ {
+ info: "order is inactive on inline element",
+ property: "order",
+ tagName: "span",
+ rules: ["span { order: 1; }"],
+ isActive: false,
+ },
+ {
+ info: "order is active on flex item",
+ property: "order",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: flex; }", "span { order: 1; }"],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: "order is active on grid item",
+ property: "order",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: grid; }", "span { order: 1; }"],
+ ruleIndex: 1,
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/float.mjs b/devtools/server/tests/chrome/inactive-property-helper/float.mjs
new file mode 100644
index 0000000000..4c502e3cca
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/float.mjs
@@ -0,0 +1,76 @@
+/* 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/. */
+
+// InactivePropertyHelper `float` test cases.
+export default [
+ {
+ info: "display: inline is inactive on a floated element",
+ property: "display",
+ tagName: "div",
+ rules: ["div { display: inline; float: right; }"],
+ isActive: false,
+ },
+ {
+ info: "display: block is active on a floated element",
+ property: "display",
+ tagName: "div",
+ rules: ["div { display: block; float: right;}"],
+ isActive: true,
+ },
+ {
+ info: "display: inline-grid is inactive on a floated element",
+ property: "display",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ container.classList.add("test");
+ rootNode.append(container);
+ return container;
+ },
+ rules: [
+ "div { float: left; display:block; }",
+ ".test { display: inline-grid ;}",
+ ],
+ isActive: false,
+ },
+ {
+ info: "display: table-footer-group is inactive on a floated element",
+ property: "display",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ container.style.display = "table";
+ const footer = document.createElement("div");
+ footer.classList.add("table-footer");
+ container.append(footer);
+ rootNode.append(container);
+ return footer;
+ },
+ rules: [".table-footer { display: table-footer-group; float: left;}"],
+ isActive: false,
+ },
+ createGridPlacementOnFloatedItemTest("grid-row"),
+ createGridPlacementOnFloatedItemTest("grid-column"),
+ createGridPlacementOnFloatedItemTest("grid-area", "foo"),
+];
+
+function createGridPlacementOnFloatedItemTest(property, value = "2") {
+ return {
+ info: `grid placement property ${property} is active on a floated grid item`,
+ property,
+ createTestElement: rootNode => {
+ const grid = document.createElement("div");
+ grid.style.display = "grid";
+ grid.style.gridTemplateRows = "repeat(5, 1fr)";
+ grid.style.gridTemplateColumns = "repeat(5, 1fr)";
+ grid.style.gridTemplateAreas = "'foo foo foo'";
+ rootNode.appendChild(grid);
+
+ const item = document.createElement("span");
+ grid.appendChild(item);
+
+ return item;
+ },
+ rules: [`span { ${property}: ${value}; float: left; }`],
+ isActive: true,
+ };
+}
diff --git a/devtools/server/tests/chrome/inactive-property-helper/gap.mjs b/devtools/server/tests/chrome/inactive-property-helper/gap.mjs
new file mode 100644
index 0000000000..83befcba0d
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/gap.mjs
@@ -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/. */
+
+// InactivePropertyHelper `gap` test cases.
+export default [
+ {
+ info: "column-gap is inactive on non-grid and non-flex container",
+ property: "column-gap",
+ tagName: "div",
+ rules: ["div { column-gap: 10px; display: block; }"],
+ isActive: false,
+ },
+ {
+ info: "column-gap is active on grid container",
+ property: "column-gap",
+ tagName: "div",
+ rules: ["div { column-gap: 10px; display: grid; }"],
+ isActive: true,
+ },
+ {
+ info: "column-gap is active on flex container",
+ property: "column-gap",
+ tagName: "div",
+ rules: ["div { column-gap: 10px; display: flex; }"],
+ isActive: true,
+ },
+ {
+ info: "column-gap is inactive on non-multi-col container",
+ property: "column-gap",
+ tagName: "div",
+ rules: ["div { column-gap: 10px; column-count: auto; }"],
+ isActive: false,
+ },
+ {
+ info: "column-gap is active on multi-column container",
+ property: "column-gap",
+ tagName: "div",
+ rules: ["div { column-gap: 10px; column-count: 2; }"],
+ isActive: true,
+ },
+ {
+ info: "row-gap is inactive on non-grid and non-flex container",
+ property: "row-gap",
+ tagName: "div",
+ rules: ["div { row-gap: 10px; display: block; }"],
+ isActive: false,
+ },
+ {
+ info: "row-gap is active on grid container",
+ property: "row-gap",
+ tagName: "div",
+ rules: ["div { row-gap: 10px; display: grid; }"],
+ isActive: true,
+ },
+ {
+ info: "row-gap is active on flex container",
+ property: "row-gap",
+ tagName: "div",
+ rules: ["div { row-gap: 10px; display: flex; }"],
+ isActive: true,
+ },
+ {
+ info: "gap is inactive on non-grid and non-flex container",
+ property: "gap",
+ tagName: "div",
+ rules: ["div { gap: 10px; display: block; }"],
+ isActive: false,
+ },
+ {
+ info: "gap is active on flex container",
+ property: "gap",
+ tagName: "div",
+ rules: ["div { gap: 10px; display: flex; }"],
+ isActive: true,
+ },
+ {
+ info: "gap is active on grid container",
+ property: "gap",
+ tagName: "div",
+ rules: ["div { gap: 10px; display: grid; }"],
+ isActive: true,
+ },
+ {
+ info: "gap is inactive on non-multi-col container",
+ property: "gap",
+ tagName: "div",
+ rules: ["div { gap: 10px; column-count: auto; }"],
+ isActive: false,
+ },
+ {
+ info: "gap is active on multi-col container",
+ property: "gap",
+ tagName: "div",
+ rules: ["div { gap: 10px; column-count: 2; }"],
+ isActive: true,
+ },
+ {
+ info: "grid-gap is inactive on non-grid and non-flex container",
+ property: "grid-gap",
+ tagName: "div",
+ rules: ["div { grid-gap: 10px; display: block; }"],
+ isActive: false,
+ },
+ {
+ info: "grid-gap is active on flex container",
+ property: "grid-gap",
+ tagName: "div",
+ rules: ["div { grid-gap: 10px; display: flex; }"],
+ isActive: true,
+ },
+ {
+ info: "grid-gap is active on grid container",
+ property: "grid-gap",
+ tagName: "div",
+ rules: ["div { grid-gap: 10px; display: grid; }"],
+ isActive: true,
+ },
+ {
+ info: "grid-gap is inactive on non-multi-col container",
+ property: "grid-gap",
+ tagName: "div",
+ rules: ["div { grid-gap: 10px; column-count: auto; }"],
+ isActive: false,
+ },
+ {
+ info: "grid-gap is active on multi-col container",
+ property: "grid-gap",
+ tagName: "div",
+ rules: ["div { grid-gap: 10px; column-count: 2; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/grid-container-properties.mjs b/devtools/server/tests/chrome/inactive-property-helper/grid-container-properties.mjs
new file mode 100644
index 0000000000..1fca234733
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/grid-container-properties.mjs
@@ -0,0 +1,43 @@
+/* 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/. */
+
+// InactivePropertyHelper test cases:
+// `grid-auto-columns`, `grid-auto-flow`, `grid-auto-rows`, `grid-template`,
+// `grid-template-areas`, `grid-template-columns`, `grid-template-rows`,
+// and `justify-items`.
+let tests = [];
+
+for (const { propertyName, propertyValue } of [
+ { propertyName: "grid-auto-columns", propertyValue: "100px" },
+ { propertyName: "grid-auto-flow", propertyValue: "columns" },
+ { propertyName: "grid-auto-rows", propertyValue: "100px" },
+ { propertyName: "grid-template", propertyValue: "auto / auto" },
+ { propertyName: "grid-template-areas", propertyValue: "a b c" },
+ { propertyName: "grid-template-columns", propertyValue: "100px 1fr" },
+ { propertyName: "grid-template-rows", propertyValue: "100px 1fr" },
+ { propertyName: "justify-items", propertyValue: "center" },
+]) {
+ tests = tests.concat(createTestsForProp(propertyName, propertyValue));
+}
+
+function createTestsForProp(propertyName, propertyValue) {
+ return [
+ {
+ info: `${propertyName} is active on a grid container`,
+ property: propertyName,
+ tagName: "div",
+ rules: [`div { display:grid; ${propertyName}: ${propertyValue}; }`],
+ isActive: true,
+ },
+ {
+ info: `${propertyName} is inactive on a non-grid container`,
+ property: propertyName,
+ tagName: "div",
+ rules: [`div { ${propertyName}: ${propertyValue}; }`],
+ isActive: false,
+ },
+ ];
+}
+
+export default tests;
diff --git a/devtools/server/tests/chrome/inactive-property-helper/grid-with-absolute-properties.mjs b/devtools/server/tests/chrome/inactive-property-helper/grid-with-absolute-properties.mjs
new file mode 100644
index 0000000000..fd963e0d3b
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/grid-with-absolute-properties.mjs
@@ -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/. */
+
+// InactivePropertyHelper test cases:
+// `grid-area`, `grid-column`, `grid-column-end`, `grid-column-start`,
+// `grid-row`, `grid-row-end`, `grid-row-start`, `justify-self`, `align-self`
+// and `place-self`.
+let tests = [];
+
+for (const { propertyName, propertyValue } of [
+ { propertyName: "grid-area", propertyValue: "2 / 1 / span 2 / span 3" },
+ { propertyName: "grid-column", propertyValue: 2 },
+ { propertyName: "grid-column-end", propertyValue: "span 3" },
+ { propertyName: "grid-column-start", propertyValue: 2 },
+ { propertyName: "grid-row", propertyValue: "1 / span 2" },
+ { propertyName: "grid-row-end", propertyValue: "span 3" },
+ { propertyName: "grid-row-start", propertyValue: 2 },
+ { propertyName: "justify-self", propertyValue: "start" },
+ { propertyName: "align-self", propertyValue: "auto" },
+ { propertyName: "place-self", propertyValue: "auto center" },
+]) {
+ tests = tests.concat(createTestsForProp(propertyName, propertyValue));
+}
+
+function createTestsForProp(propertyName, propertyValue) {
+ return [
+ {
+ info: `${propertyName} is active on a grid item`,
+ property: `${propertyName}`,
+ createTestElement,
+ rules: [
+ `#grid-container { display:grid; grid:auto/100px 100px; }`,
+ `#grid-item { ${propertyName}: ${propertyValue}; }`,
+ ],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: `${propertyName} is active on an absolutely positioned grid item`,
+ property: `${propertyName}`,
+ createTestElement,
+ rules: [
+ `#grid-container { display:grid; grid:auto/100px 100px; position: relative }`,
+ `#grid-item { ${propertyName}: ${propertyValue}; position: absolute; }`,
+ ],
+ ruleIndex: 1,
+ isActive: true,
+ },
+ {
+ info: `${propertyName} is inactive on a non-grid item`,
+ property: `${propertyName}`,
+ tagName: `div`,
+ rules: [`div { ${propertyName}: ${propertyValue}; }`],
+ isActive: false,
+ },
+ ];
+}
+
+function createTestElement(rootNode) {
+ const container = document.createElement("div");
+ container.id = "grid-container";
+ const element = document.createElement("div");
+ element.id = "grid-item";
+ container.append(element);
+ rootNode.append(container);
+
+ return element;
+}
+
+export default tests;
diff --git a/devtools/server/tests/chrome/inactive-property-helper/highlight-pseudo-elements.mjs b/devtools/server/tests/chrome/inactive-property-helper/highlight-pseudo-elements.mjs
new file mode 100644
index 0000000000..bcb5b8763c
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/highlight-pseudo-elements.mjs
@@ -0,0 +1,155 @@
+/* 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/. */
+
+// InactivePropertyHelper `highlight-pseudo-elements` test cases.
+
+// "background",
+// "background-color",
+// "color",
+// "fill-color",
+// "stroke-color",
+// "stroke-width",
+// "text-decoration",
+// "text-shadow",
+// "text-underline-offset",
+// "text-underline-position",
+
+export default [
+ {
+ info: "width is inactive on ::selection",
+ property: "width",
+ tagName: "span",
+ rules: ["span::selection { width: 10px; }"],
+ isActive: false,
+ // `width` is also inactive on inline element, so make sure we get the warning
+ // because we're using it in a highlight pseudo.
+ expectedMsgId: "inactive-css-highlight-pseudo-elements-not-supported",
+ },
+ {
+ info: "display is inactive on ::highlight",
+ property: "display",
+ tagName: "span",
+ rules: ["span::highlight(result) { display: grid; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-highlight-pseudo-elements-not-supported",
+ },
+ {
+ // accept background shorthand, even if it might hold inactive values
+ info: "background is active on ::selection",
+ property: "background",
+ tagName: "span",
+ rules: ["span::selection { background: red; }"],
+ isActive: true,
+ },
+ {
+ info: "border-color is inactive on ::selection",
+ property: "border-color",
+ tagName: "span",
+ rules: ["span::selection { border-color: red; }"],
+ isActive: false,
+ // `width` is also inactive on inline element, so make sure we get the warning
+ // because we're using it in a highlight pseudo.
+ expectedMsgId: "inactive-css-highlight-pseudo-elements-not-supported",
+ },
+ {
+ info: "background-color is active on ::selection",
+ property: "background-color",
+ tagName: "span",
+ rules: ["span::selection { background-color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "color is active on ::selection",
+ property: "color",
+ tagName: "span",
+ rules: ["span::selection { color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration is active on ::selection",
+ property: "text-decoration",
+ tagName: "span",
+ rules: [
+ "span::selection { text-decoration: double overline #FF3028 4px; }",
+ ],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-color is active on ::selection",
+ property: "text-decoration-color",
+ tagName: "span",
+ rules: ["span::selection { text-decoration-color: #FF3028; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-line is active on ::selection",
+ property: "text-decoration-line",
+ tagName: "span",
+ rules: ["span::selection { text-decoration-line: overline; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-style is active on ::selection",
+ property: "text-decoration-style",
+ tagName: "span",
+ rules: ["span::selection { text-decoration-style: double; }"],
+ isActive: true,
+ },
+ {
+ info: "text-decoration-thickness is active on ::selection",
+ property: "text-decoration-thickness",
+ tagName: "span",
+ rules: ["span::selection { text-decoration-thickness: 4px; }"],
+ isActive: true,
+ },
+ {
+ info: "text-shadow is active on ::selection",
+ property: "text-shadow",
+ tagName: "span",
+ rules: ["span::selection { text-shadow: text-shadow: #FC0 1px 0 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "text-underline-offset is active on ::selection",
+ property: "text-underline-offset",
+ tagName: "span",
+ rules: ["span::selection { text-underline-offset: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "text-underline-position is active on ::selection",
+ property: "text-underline-position",
+ tagName: "span",
+ rules: ["span::selection { text-underline-position: under; }"],
+ isActive: true,
+ },
+ {
+ info: "-webkit-text-fill-color is active on ::selection",
+ property: "-webkit-text-fill-color",
+ tagName: "span",
+ rules: ["span::selection { -webkit-text-fill-color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "-webkit-text-stroke-color is active on ::selection",
+ property: "-webkit-text-stroke-color",
+ tagName: "span",
+ rules: ["span::selection { -webkit-text-stroke-color: red; }"],
+ isActive: true,
+ },
+ {
+ info: "-webkit-text-stroke-width is active on ::selection",
+ property: "-webkit-text-stroke-width",
+ tagName: "span",
+ rules: ["span::selection { -webkit-text-stroke-width: 4px; }"],
+ isActive: true,
+ },
+ {
+ info: "-webkit-text-stroke is active on ::selection",
+ property: "-webkit-text-stroke",
+ tagName: "span",
+ rules: ["span::selection { -webkit-text-stroke: 4px navy; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/margin-padding.mjs b/devtools/server/tests/chrome/inactive-property-helper/margin-padding.mjs
new file mode 100644
index 0000000000..7c1d348512
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/margin-padding.mjs
@@ -0,0 +1,260 @@
+/* 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/. */
+
+// InactivePropertyHelper `align-content` test cases.
+
+export default [
+ {
+ info: "margin is active on block containers",
+ property: "margin",
+ tagName: "div",
+ rules: ["div { margin: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "margin is active on flex containers",
+ property: "margin",
+ tagName: "div",
+ rules: ["div { display: flex; margin: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "margin is active on grid containers",
+ property: "margin",
+ tagName: "div",
+ rules: ["div { display: grid; margin: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "margin is active on tables",
+ property: "margin",
+ tagName: "div",
+ rules: ["div { display: table; margin: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "margin is active on inline tables",
+ property: "margin",
+ tagName: "div",
+ rules: ["div { display: inline-table; margin: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "margin is active on table captions",
+ property: "margin",
+ tagName: "div",
+ rules: ["div { display: table-caption; margin: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "margin is inactive on table cells",
+ property: "margin",
+ tagName: "div",
+ rules: ["div { display: table-cell; margin: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-block is inactive on table cells",
+ property: "margin-block",
+ tagName: "div",
+ rules: ["div { display: table-cell; margin-block: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-block-start is inactive on table cells",
+ property: "margin-block-start",
+ tagName: "div",
+ rules: ["div { display: table-cell; margin-block-start: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-block-end is inactive on table cells",
+ property: "margin-block-end",
+ tagName: "div",
+ rules: ["div { display: table-cell; margin-block-end: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-block is inactive on table cells",
+ property: "margin-block",
+ tagName: "div",
+ rules: ["div { display: table-cell; margin-block: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-bottom is inactive on table rows",
+ property: "margin-bottom",
+ tagName: "div",
+ rules: ["div { display: table-row; margin-bottom: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-inline-start is inactive on table rows",
+ property: "margin-inline-start",
+ tagName: "div",
+ rules: ["div { display: table-row; margin-inline-start: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-inline-end is inactive on table rows",
+ property: "margin-inline-end",
+ tagName: "div",
+ rules: ["div { display: table-row; margin-inline-end: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-inline is inactive on table rows",
+ property: "margin-inline",
+ tagName: "div",
+ rules: ["div { display: table-row; margin-inline: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-left is inactive on table columns",
+ property: "margin-left",
+ tagName: "div",
+ rules: ["div { display: table-column; margin-left: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-right is inactive on table row groups",
+ property: "margin-right",
+ tagName: "div",
+ rules: ["div { display: table-row-group; margin-right: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "margin-top is inactive on table column groups",
+ property: "margin-top",
+ tagName: "div",
+ rules: ["div { display: table-column-group; margin-top: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "padding is active on block containers",
+ property: "padding",
+ tagName: "div",
+ rules: ["div { padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding is active on flex containers",
+ property: "padding",
+ tagName: "div",
+ rules: ["div { display: flex; padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding is active on grid containers",
+ property: "padding",
+ tagName: "div",
+ rules: ["div { display: grid; padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding is active on tables",
+ property: "padding",
+ tagName: "div",
+ rules: ["div { display: table; padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding is active on inline tables",
+ property: "padding",
+ tagName: "div",
+ rules: ["div { display: inline-table; padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding is active on table captions",
+ property: "padding",
+ tagName: "div",
+ rules: ["div { display: table-caption; padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding is active on table cells",
+ property: "padding",
+ tagName: "div",
+ rules: ["div { display: table-cell; padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding-block is active on table cells",
+ property: "padding-block",
+ tagName: "div",
+ rules: ["div { display: table-cell; padding-block: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding-block-start is active on table cells",
+ property: "padding-block-start",
+ tagName: "div",
+ rules: ["div { display: table-cell; padding-block-start: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding-block-end is active on table cells",
+ property: "padding-block-end",
+ tagName: "div",
+ rules: ["div { display: table-cell; padding-block-end: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding-block is active on table cells",
+ property: "padding-block",
+ tagName: "div",
+ rules: ["div { display: table-cell; padding-block: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "padding-bottom is inactive on table rows",
+ property: "padding-bottom",
+ tagName: "div",
+ rules: ["div { display: table-row; padding-bottom: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "padding-inline-start is inactive on table rows",
+ property: "padding-inline-start",
+ tagName: "div",
+ rules: ["div { display: table-row; padding-inline-start: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "padding-inline-end is inactive on table rows",
+ property: "padding-inline-end",
+ tagName: "div",
+ rules: ["div { display: table-row; padding-inline-end: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "padding-inline is inactive on table rows",
+ property: "padding-inline",
+ tagName: "div",
+ rules: ["div { display: table-row; padding-inline: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "padding-left is inactive on table columns",
+ property: "padding-left",
+ tagName: "div",
+ rules: ["div { display: table-column; padding-left: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "padding-right is inactive on table row groups",
+ property: "padding-right",
+ tagName: "div",
+ rules: ["div { display: table-row-group; padding-right: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "padding-top is inactive on table column groups",
+ property: "padding-top",
+ tagName: "div",
+ rules: ["div { display: table-column-group; padding-top: 10px; }"],
+ isActive: false,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/max-min-width-height.mjs b/devtools/server/tests/chrome/inactive-property-helper/max-min-width-height.mjs
new file mode 100644
index 0000000000..4bb5623f6e
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/max-min-width-height.mjs
@@ -0,0 +1,366 @@
+/* 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/. */
+
+// InactivePropertyHelper `width`, `min-width`, `max-width`, `height`, `min-height`,
+// `max-height` test cases.
+export default [
+ {
+ info: "width is inactive on a non-replaced inline element",
+ property: "width",
+ tagName: "span",
+ rules: ["span { width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-width is inactive on a non-replaced inline element",
+ property: "min-width",
+ tagName: "span",
+ rules: ["span { min-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-width is inactive on a non-replaced inline element",
+ property: "max-width",
+ tagName: "span",
+ rules: ["span { max-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "width is inactive on an tr element",
+ property: "width",
+ tagName: "tr",
+ rules: ["tr { width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-width is inactive on an tr element",
+ property: "min-width",
+ tagName: "tr",
+ rules: ["tr { min-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-width is inactive on an tr element",
+ property: "max-width",
+ tagName: "tr",
+ rules: ["tr { max-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "width is inactive on an thead element",
+ property: "width",
+ tagName: "thead",
+ rules: ["thead { width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-width is inactive on an thead element",
+ property: "min-width",
+ tagName: "thead",
+ rules: ["thead { min-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-width is inactive on an thead element",
+ property: "max-width",
+ tagName: "thead",
+ rules: ["thead { max-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "width is inactive on an tfoot element",
+ property: "width",
+ tagName: "tfoot",
+ rules: ["tfoot { width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-width is inactive on an tfoot element",
+ property: "min-width",
+ tagName: "tfoot",
+ rules: ["tfoot { min-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-width is inactive on an tfoot element",
+ property: "max-width",
+ tagName: "tfoot",
+ rules: ["tfoot { max-width: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "width is active on a replaced inline element",
+ property: "width",
+ tagName: "img",
+ rules: ["img { width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "width is active on an inline input element",
+ property: "width",
+ tagName: "input",
+ rules: ["input { display: inline; width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "width is active on an inline select element",
+ property: "width",
+ tagName: "select",
+ rules: ["select { display: inline; width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "width is active on a textarea element",
+ property: "width",
+ tagName: "textarea",
+ rules: ["textarea { width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "min-width is active on a replaced inline element",
+ property: "min-width",
+ tagName: "img",
+ rules: ["img { min-width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "max-width is active on a replaced inline element",
+ property: "max-width",
+ tagName: "img",
+ rules: ["img { max-width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "width is active on a block element",
+ property: "width",
+ tagName: "div",
+ rules: ["div { width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "min-width is active on a block element",
+ property: "min-width",
+ tagName: "div",
+ rules: ["div { min-width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "max-width is active on a block element",
+ property: "max-width",
+ tagName: "div",
+ rules: ["div { max-width: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "height is inactive on a non-replaced inline element",
+ property: "height",
+ tagName: "span",
+ rules: ["span { height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-height is inactive on a non-replaced inline element",
+ property: "min-height",
+ tagName: "span",
+ rules: ["span { min-height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-height is inactive on a non-replaced inline element",
+ property: "max-height",
+ tagName: "span",
+ rules: ["span { max-height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "height is inactive on colgroup element",
+ property: "height",
+ tagName: "colgroup",
+ rules: ["colgroup { height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-height is inactive on colgroup element",
+ property: "min-height",
+ tagName: "colgroup",
+ rules: ["colgroup { min-height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-height is inactive on colgroup element",
+ property: "max-height",
+ tagName: "colgroup",
+ rules: ["colgroup { max-height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "height is inactive on col element",
+ property: "height",
+ tagName: "col",
+ rules: ["col { height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-height is inactive on col element",
+ property: "min-height",
+ tagName: "col",
+ rules: ["col { min-height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-height is inactive on col element",
+ property: "max-height",
+ tagName: "col",
+ rules: ["col { max-height: 500px; }"],
+ isActive: false,
+ },
+ {
+ info: "height is active on a replaced inline element",
+ property: "height",
+ tagName: "img",
+ rules: ["img { height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "height is active on an inline input element",
+ property: "height",
+ tagName: "input",
+ rules: ["input { display: inline; height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "height is active on an inline select element",
+ property: "height",
+ tagName: "select",
+ rules: ["select { display: inline; height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "height is active on a textarea element",
+ property: "height",
+ tagName: "textarea",
+ rules: ["textarea { height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "min-height is active on a replaced inline element",
+ property: "min-height",
+ tagName: "img",
+ rules: ["img { min-height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "max-height is active on a replaced inline element",
+ property: "max-height",
+ tagName: "img",
+ rules: ["img { max-height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "height is active on a block element",
+ property: "height",
+ tagName: "div",
+ rules: ["div { height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "min-height is active on a block element",
+ property: "min-height",
+ tagName: "div",
+ rules: ["div { min-height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "max-height is active on a block element",
+ property: "max-height",
+ tagName: "div",
+ rules: ["div { max-height: 500px; }"],
+ isActive: true,
+ },
+ {
+ info: "height is active on an svg <rect> element.",
+ property: "height",
+ createTestElement: main => {
+ main.innerHTML = `
+ <svg width=100 height=100>
+ <rect width=100 fill=green></rect>
+ </svg>
+ `;
+ return main.querySelector("rect");
+ },
+ rules: ["rect { height: 100px; }"],
+ isActive: true,
+ },
+ createTableElementTestCase("width", false, "table-row"),
+ createTableElementTestCase("width", false, "table-row-group"),
+ createTableElementTestCase("width", true, "table-column"),
+ createTableElementTestCase("width", true, "table-column-group"),
+ createTableElementTestCase("height", false, "table-column"),
+ createTableElementTestCase("height", false, "table-column-group"),
+ createTableElementTestCase("height", true, "table-row"),
+ createTableElementTestCase("height", true, "table-row-group"),
+ createVerticalTableElementTestCase("width", true, "table-row"),
+ createVerticalTableElementTestCase("width", true, "table-row-group"),
+ createVerticalTableElementTestCase("width", false, "table-column"),
+ createVerticalTableElementTestCase("width", false, "table-column-group"),
+ createVerticalTableElementTestCase("height", true, "table-column"),
+ createVerticalTableElementTestCase("height", true, "table-column-group"),
+ createVerticalTableElementTestCase("height", false, "table-row"),
+ createVerticalTableElementTestCase("height", false, "table-row-group"),
+ {
+ info: "width's inactivity status for a row takes the table's writing mode into account",
+ property: "width",
+ createTestElement: rootNode => {
+ const table = document.createElement("table");
+ table.style.writingMode = "vertical-lr";
+ rootNode.appendChild(table);
+
+ const tbody = document.createElement("tbody");
+ table.appendChild(tbody);
+
+ const tr = document.createElement("tr");
+ tbody.appendChild(tr);
+
+ const td = document.createElement("td");
+ tr.appendChild(td);
+
+ return tr;
+ },
+ rules: ["tr { writing-mode: horizontal-tb; width: 360px; }"],
+ isActive: true,
+ },
+];
+
+function createTableElementTestCase(property, isActive, displayType) {
+ return {
+ info: `${property} is ${
+ isActive ? "active" : "inactive"
+ } on a ${displayType}`,
+ property,
+ tagName: "div",
+ rules: [`div { display: ${displayType}; ${property}: 100px; }`],
+ isActive,
+ };
+}
+
+function createVerticalTableElementTestCase(property, isActive, displayType) {
+ return {
+ info: `${property} is ${
+ isActive ? "active" : "inactive"
+ } on a vertical ${displayType}`,
+ property,
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ container.style.writingMode = "vertical-lr";
+ rootNode.append(container);
+
+ const element = document.createElement("span");
+ container.append(element);
+
+ return element;
+ },
+ rules: [`span { display: ${displayType}; ${property}: 100px; }`],
+ isActive,
+ };
+}
diff --git a/devtools/server/tests/chrome/inactive-property-helper/multicol-container-properties.mjs b/devtools/server/tests/chrome/inactive-property-helper/multicol-container-properties.mjs
new file mode 100644
index 0000000000..6bc4e9dd13
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/multicol-container-properties.mjs
@@ -0,0 +1,39 @@
+/* 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/. */
+
+// InactivePropertyHelper test cases:
+// `column-fill`, `column-rule`, `column-rule-color`, `column-rule-style`,
+// and `column-rule-width`.
+let tests = [];
+
+for (const { propertyName, propertyValue } of [
+ { propertyName: "column-fill", propertyValue: "auto" },
+ { propertyName: "column-rule", propertyValue: "1px solid black" },
+ { propertyName: "column-rule-color", propertyValue: "black" },
+ { propertyName: "column-rule-style", propertyValue: "solid" },
+ { propertyName: "column-rule-width", propertyValue: "1px" },
+]) {
+ tests = tests.concat(createTestsForProp(propertyName, propertyValue));
+}
+
+function createTestsForProp(propertyName, propertyValue) {
+ return [
+ {
+ info: `${propertyName} is active on a multi-column container`,
+ property: propertyName,
+ tagName: "div",
+ rules: [`div { columns:2; ${propertyName}: ${propertyValue}; }`],
+ isActive: true,
+ },
+ {
+ info: `${propertyName} is inactive on a non-multi-column container`,
+ property: propertyName,
+ tagName: "div",
+ rules: [`div { ${propertyName}: ${propertyValue}; }`],
+ isActive: false,
+ },
+ ];
+}
+
+export default tests;
diff --git a/devtools/server/tests/chrome/inactive-property-helper/place-items-content.mjs b/devtools/server/tests/chrome/inactive-property-helper/place-items-content.mjs
new file mode 100644
index 0000000000..f554a785a7
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/place-items-content.mjs
@@ -0,0 +1,159 @@
+/* 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/. */
+
+// InactivePropertyHelper `place-items` and `place-content` test cases.
+export default [
+ {
+ info: "place-items is inactive on block element",
+ property: "place-items",
+ tagName: "div",
+ rules: ["div { place-items: center; }"],
+ isActive: false,
+ },
+ {
+ info: "place-items is inactive on inline element",
+ property: "place-items",
+ tagName: "span",
+ rules: ["span { place-items: center; }"],
+ isActive: false,
+ },
+ {
+ info: "place-items is inactive on flex item",
+ property: "place-items",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: flex; align-items: start; }",
+ "span { place-items: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: false,
+ },
+ {
+ info: "place-items is inactive on grid item",
+ property: "place-items",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: grid; align-items: start; }",
+ "span { place-items: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: false,
+ },
+ {
+ info: "place-items is active on flex container",
+ property: "place-items",
+ tagName: "div",
+ rules: ["div { place-items: center; display: flex;}"],
+ isActive: true,
+ },
+ {
+ info: "place-items is active on inline-flex container",
+ property: "place-items",
+ tagName: "div",
+ rules: ["div { place-items: center; display: inline-flex;}"],
+ isActive: true,
+ },
+ {
+ info: "place-items is active on grid container",
+ property: "place-items",
+ tagName: "div",
+ rules: ["div { place-items: center; display: grid;}"],
+ isActive: true,
+ },
+ {
+ info: "place-items is active on inline grid container",
+ property: "place-items",
+ tagName: "div",
+ rules: ["div { place-items: center; display: inline-grid;}"],
+ isActive: true,
+ },
+ {
+ info: "place-content is inactive on block element",
+ property: "place-content",
+ tagName: "div",
+ rules: ["div { place-content: center; }"],
+ isActive: false,
+ },
+ {
+ info: "place-content is inactive on inline element",
+ property: "place-content",
+ tagName: "span",
+ rules: ["span { place-content: center; }"],
+ isActive: false,
+ },
+ {
+ info: "place-content is inactive on flex item",
+ property: "place-content",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: flex; align-items: start; }",
+ "span { place-content: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: false,
+ },
+ {
+ info: "place-content is inactive on grid item",
+ property: "place-content",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: [
+ "div { display: grid; align-items: start; }",
+ "span { place-content: center; }",
+ ],
+ ruleIndex: 1,
+ isActive: false,
+ },
+ {
+ info: "place-content is active on flex container",
+ property: "place-content",
+ tagName: "div",
+ rules: ["div { place-content: center; display: flex;}"],
+ isActive: true,
+ },
+ {
+ info: "place-content is active on inline-flex container",
+ property: "place-content",
+ tagName: "div",
+ rules: ["div { place-content: center; display: inline-flex;}"],
+ isActive: true,
+ },
+ {
+ info: "place-content is active on grid container",
+ property: "place-content",
+ tagName: "div",
+ rules: ["div { place-content: center; display: grid;}"],
+ isActive: true,
+ },
+ {
+ info: "place-content is active on inline grid container",
+ property: "place-content",
+ tagName: "div",
+ rules: ["div { place-content: center; display: inline-grid;}"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/placeholder-pseudo-element.mjs b/devtools/server/tests/chrome/inactive-property-helper/placeholder-pseudo-element.mjs
new file mode 100644
index 0000000000..6c9a81472b
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/placeholder-pseudo-element.mjs
@@ -0,0 +1,122 @@
+/* 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/. */
+
+// InactivePropertyHelper `placeholder-pseudo-element` test cases.
+
+//"baseline-source",
+//"direction",
+//"dominant-baseline",
+//"line-height",
+//"text-orientation",
+//"vertical-align",
+//"writing-mode",
+//"alignment-baseline",
+//"baseline-shift",
+//"initial-letter",
+//"text-box-trim",
+
+export default [
+ {
+ info: "baseline-source is inactive on ::placeholder",
+ property: "baseline-source",
+ tagName: "input",
+ rules: ["input::placeholder { baseline-source: first; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "direction is inactive on ::placeholder",
+ property: "direction",
+ tagName: "input",
+ rules: ["input::placeholder { direction: rtl; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "dominant-baseline is inactive on ::placeholder",
+ property: "dominant-baseline",
+ tagName: "input",
+ rules: ["input::placeholder { dominant-baseline: central; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "line-height is inactive on ::placeholder",
+ property: "line-height",
+ tagName: "input",
+ rules: ["input::placeholder { line-height: 2em; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "text-orientation is inactive on ::placeholder",
+ property: "text-orientation",
+ tagName: "input",
+ rules: ["input::placeholder { text-orientation: sideways; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "vertical-align is inactive on ::placeholder",
+ property: "vertical-align",
+ tagName: "input",
+ rules: ["input::placeholder { vertical-align: super; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "writing-mode is inactive on ::placeholder",
+ property: "writing-mode",
+ tagName: "input",
+ rules: ["input::placeholder { writing-mode: vertical-rl; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "alignment-baseline is inactive on ::placeholder",
+ property: "alignment-baseline",
+ tagName: "input",
+ rules: ["input::placeholder { alignment-baseline: central; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "baseline-shift is inactive on ::placeholder",
+ property: "baseline-shift",
+ tagName: "input",
+ rules: ["input::placeholder { baseline-shift: super; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "initial-letter is inactive on ::placeholder",
+ property: "initial-letter",
+ tagName: "input",
+ rules: ["input::placeholder { initial-letter: 2em; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "text-box-trim is inactive on ::placeholder",
+ property: "text-box-trim",
+ tagName: "input",
+ rules: ["input::placeholder { text-box-trim: both; }"],
+ isActive: false,
+ expectedMsgId: "inactive-css-placeholder-pseudo-element-not-supported",
+ },
+ {
+ info: "color is active on ::placeholder",
+ property: "color",
+ tagName: "input",
+ rules: ["input::placeholder { color: green; }"],
+ isActive: true,
+ },
+ {
+ info: "display is active on ::placeholder",
+ property: "display",
+ tagName: "input",
+ rules: ["input::placeholder { display: grid; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/positioned.mjs b/devtools/server/tests/chrome/inactive-property-helper/positioned.mjs
new file mode 100644
index 0000000000..0386c278c5
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/positioned.mjs
@@ -0,0 +1,82 @@
+/* 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/. */
+
+// InactivePropertyHelper positioned elements test cases.
+
+// These are the properties we care about, those that are inactive when the element isn't
+// positioned.
+const PROPERTIES = [
+ { property: "z-index", value: "2" },
+ { property: "top", value: "20px" },
+ { property: "right", value: "20px" },
+ { property: "bottom", value: "20px" },
+ { property: "left", value: "20px" },
+];
+
+// These are all of the different position values and whether the above properties are
+// active or not for each.
+const POSITIONS = [
+ { position: "unset", isActive: false },
+ { position: "initial", isActive: false },
+ { position: "inherit", isActive: false },
+ { position: "static", isActive: false },
+ { position: "absolute", isActive: true },
+ { position: "relative", isActive: true },
+ { position: "fixed", isActive: true },
+ { position: "sticky", isActive: true },
+];
+
+function makeTestCase(property, value, position, isActive) {
+ return {
+ info: `${property} is ${
+ isActive ? "" : "in"
+ }active when position is ${position}`,
+ property,
+ tagName: "div",
+ rules: [`div { ${property}: ${value}; position: ${position}; }`],
+ isActive,
+ };
+}
+
+// Make the test cases for all the combinations of PROPERTIES and POSITIONS
+const mainTests = [];
+
+for (const { property, value } of PROPERTIES) {
+ for (const { position, isActive } of POSITIONS) {
+ mainTests.push(makeTestCase(property, value, position, isActive));
+ }
+}
+
+// Add a few test cases to check that z-index actually works inside grids and flexboxes.
+mainTests.push({
+ info: "z-index is active even on unpositioned elements if they are grid items",
+ property: "z-index",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: grid; }", "span { z-index: 3; }"],
+ ruleIndex: 1,
+ isActive: true,
+});
+
+mainTests.push({
+ info: "z-index is active even on unpositioned elements if they are flex items",
+ property: "z-index",
+ createTestElement: rootNode => {
+ const container = document.createElement("div");
+ const element = document.createElement("span");
+ container.append(element);
+ rootNode.append(container);
+ return element;
+ },
+ rules: ["div { display: flex; }", "span { z-index: 3; }"],
+ ruleIndex: 1,
+ isActive: true,
+});
+
+export default mainTests;
diff --git a/devtools/server/tests/chrome/inactive-property-helper/scroll-padding.mjs b/devtools/server/tests/chrome/inactive-property-helper/scroll-padding.mjs
new file mode 100644
index 0000000000..acb2899be2
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/scroll-padding.mjs
@@ -0,0 +1,159 @@
+/* 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/. */
+
+// InactivePropertyHelper `scroll-padding-*` test cases.
+
+export default [
+ {
+ info: "scroll-padding is active on element with auto-overflow",
+ property: "scroll-padding",
+ tagName: "div",
+ rules: ["div { overflow: auto; scroll-padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "scroll-padding is active on element with scrollable overflow",
+ property: "scroll-padding",
+ tagName: "div",
+ rules: ["div { overflow: scroll; scroll-padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "scroll-padding is active on element with hidden overflow",
+ property: "scroll-padding",
+ tagName: "div",
+ rules: ["div { overflow: hidden; scroll-padding: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "scroll-padding is inactive on element with visible overflow",
+ property: "scroll-padding",
+ tagName: "div",
+ rules: ["div { scroll-padding: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding is inactive on element with clipped overflow",
+ property: "scroll-padding",
+ tagName: "div",
+ rules: ["div { overflow: clip; scroll-padding: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding is inactive on element with horizontally clipped overflow",
+ property: "scroll-padding",
+ tagName: "div",
+ rules: ["div { overflow-x: clip; scroll-padding: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding is inactive on element with vertically clipped overflow",
+ property: "scroll-padding",
+ tagName: "div",
+ rules: ["div { overflow-y: clip; scroll-padding: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-top is inactive on element with visible overflow",
+ property: "scroll-padding-top",
+ tagName: "div",
+ rules: ["div { scroll-padding-top: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-top is inactive on element with horizontally clipped overflow",
+ property: "scroll-padding-top",
+ tagName: "div",
+ rules: ["div { overflow-x: clip; scroll-padding-top: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-top is inactive on element with vertically clipped overflow",
+ property: "scroll-padding-top",
+ tagName: "div",
+ rules: ["div { overflow-y: clip; scroll-padding-top: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-top is active on element with horizontally clipped but vertical auto-overflow (as 'clip' is computed to 'hidden')",
+ property: "scroll-padding-top",
+ tagName: "div",
+ rules: [
+ "div { overflow-x: clip; overflow-y: auto; scroll-padding-top: 10px; }",
+ ],
+ isActive: true,
+ },
+ {
+ info: "scroll-padding-top is active on element with vertically clipped but horizontal auto-overflow (as 'clip' is computed to 'hidden')",
+ property: "scroll-padding-top",
+ tagName: "div",
+ rules: [
+ "div { overflow-x: auto; overflow-y: clip; scroll-padding-top: 10px; }",
+ ],
+ isActive: true,
+ },
+ {
+ info: "scroll-padding-right is inactive on element with visible overflow",
+ property: "scroll-padding-right",
+ tagName: "div",
+ rules: ["div { scroll-padding-right: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-bottom is inactive on element with visible overflow",
+ property: "scroll-padding-bottom",
+ tagName: "div",
+ rules: ["div { scroll-padding-bottom: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-left is inactive on element with visible overflow",
+ property: "scroll-padding-left",
+ tagName: "div",
+ rules: ["div { scroll-padding-left: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-block is inactive on element with visible overflow",
+ property: "scroll-padding-block",
+ tagName: "div",
+ rules: ["div { scroll-padding-block: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-block-end is inactive on element with visible overflow",
+ property: "scroll-padding-block-end",
+ tagName: "div",
+ rules: ["div { scroll-padding-block-end: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-block-start is inactive on element with visible overflow",
+ property: "scroll-padding-block-start",
+ tagName: "div",
+ rules: ["div { scroll-padding-block-start: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-inline is inactive on element with visible overflow",
+ property: "scroll-padding-inline",
+ tagName: "div",
+ rules: ["div { scroll-padding-inline: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-inline-end is inactive on element with visible overflow",
+ property: "scroll-padding-inline-end",
+ tagName: "div",
+ rules: ["div { scroll-padding-inline-end: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "scroll-padding-inline-start is inactive on element with visible overflow",
+ property: "scroll-padding-inline-start",
+ tagName: "div",
+ rules: ["div { scroll-padding-inline-start: 10px; }"],
+ isActive: false,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/table-cell.mjs b/devtools/server/tests/chrome/inactive-property-helper/table-cell.mjs
new file mode 100644
index 0000000000..bda1f27015
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/table-cell.mjs
@@ -0,0 +1,21 @@
+/* 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/. */
+
+// InactivePropertyHelper `empty-cells` test cases.
+export default [
+ {
+ info: "empty-cells is inactive on block element",
+ property: "empty-cells",
+ tagName: "div",
+ rules: ["div { empty-cells: hide; }"],
+ isActive: false,
+ },
+ {
+ info: "empty-cells is active on table cell element",
+ property: "empty-cells",
+ tagName: "div",
+ rules: ["div { display: table-cell; empty-cells: hide; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/table.mjs b/devtools/server/tests/chrome/inactive-property-helper/table.mjs
new file mode 100644
index 0000000000..596698522c
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/table.mjs
@@ -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/. */
+
+// InactivePropertyHelper `table-layout` test cases.
+export default [
+ {
+ info: "table-layout is inactive on block element",
+ property: "table-layout",
+ tagName: "div",
+ rules: ["div { table-layout: fixed; }"],
+ isActive: false,
+ },
+ {
+ info: "table-layout is active on table element",
+ property: "table-layout",
+ tagName: "div",
+ rules: ["div { display: table; table-layout: fixed; }"],
+ isActive: true,
+ },
+ {
+ info: "table-layout is active on inline table element",
+ property: "table-layout",
+ tagName: "div",
+ rules: ["div { display: inline-table; table-layout: fixed; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/text-overflow.mjs b/devtools/server/tests/chrome/inactive-property-helper/text-overflow.mjs
new file mode 100644
index 0000000000..ada2211b3a
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/text-overflow.mjs
@@ -0,0 +1,92 @@
+/* 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/. */
+
+// InactivePropertyHelper `text-overflow` test cases.
+export default [
+ {
+ info: "text-overflow is inactive when overflow is not set",
+ property: "text-overflow",
+ tagName: "div",
+ rules: ["div { text-overflow: ellipsis; }"],
+ isActive: false,
+ },
+ {
+ info: "text-overflow is active when overflow is set to hidden",
+ property: "text-overflow",
+ tagName: "div",
+ rules: ["div { text-overflow: ellipsis; overflow: hidden; }"],
+ isActive: true,
+ },
+ {
+ info: "text-overflow is active when overflow is set to auto",
+ property: "text-overflow",
+ tagName: "div",
+ rules: ["div { text-overflow: ellipsis; overflow: auto; }"],
+ isActive: true,
+ },
+ {
+ info: "text-overflow is active when overflow is set to scroll",
+ property: "text-overflow",
+ tagName: "div",
+ rules: ["div { text-overflow: ellipsis; overflow: scroll; }"],
+ isActive: true,
+ },
+ {
+ info: "text-overflow is inactive when overflow is set to visible",
+ property: "text-overflow",
+ tagName: "div",
+ rules: ["div { text-overflow: ellipsis; overflow: visible; }"],
+ isActive: false,
+ },
+ {
+ info: "text-overflow is active when overflow-x is set to hidden on horizontal writing mode",
+ property: "text-overflow",
+ tagName: "div",
+ rules: [
+ "div { writing-mode: lr; text-overflow: ellipsis; overflow-x: hidden; }",
+ ],
+ isActive: true,
+ },
+ {
+ info: "text-overflow is inactive when overflow-x is set to visible on horizontal writing mode",
+ property: "text-overflow",
+ tagName: "div",
+ rules: [
+ "div { writing-mode: lr; text-overflow: ellipsis; overflow-x: visible; }",
+ ],
+ isActive: false,
+ },
+ {
+ info: "text-overflow is active when overflow-y is set to hidden on vertical writing mode",
+ property: "text-overflow",
+ tagName: "div",
+ rules: [
+ "div { writing-mode: vertical-lr; text-overflow: ellipsis; overflow-y: hidden; }",
+ ],
+ isActive: true,
+ },
+ {
+ info: "text-overflow is inactive when overflow-y is set to visible on vertical writing mode",
+ property: "text-overflow",
+ tagName: "div",
+ rules: [
+ "div { writing-mode: vertical-lr; text-overflow: ellipsis; overflow-y: visible; }",
+ ],
+ isActive: false,
+ },
+ {
+ info: "as soon as overflow:hidden is set, text-overflow is active whatever the box type",
+ property: "text-overflow",
+ tagName: "span",
+ rules: ["span { text-overflow: ellipsis; overflow: hidden; }"],
+ isActive: true,
+ },
+ {
+ info: "as soon as overflow:hidden is set, text-overflow is active whatever the box type",
+ property: "text-overflow",
+ tagName: "legend",
+ rules: ["legend { text-overflow: ellipsis; overflow: hidden; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/text-wrap.mjs b/devtools/server/tests/chrome/inactive-property-helper/text-wrap.mjs
new file mode 100644
index 0000000000..58751aa764
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/text-wrap.mjs
@@ -0,0 +1,86 @@
+/* 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/. */
+
+// InactivePropertyHelper `text-wrap: balance` test cases.
+const LOREM_IPSUM = `
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Donec a diam lectus. Sed sit amet ipsum mauris.
+ Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit.
+`;
+
+export default [
+ {
+ info: "text-wrap: balance; is inactive when line number exceeds threshold",
+ property: "text-wrap",
+ createTestElement: rootNode => {
+ const element = document.createElement("div");
+ element.textContent = LOREM_IPSUM;
+ rootNode.append(element);
+ return element;
+ },
+ tagName: "div",
+ rules: ["div { text-wrap: balance; width: 100px; }"],
+ isActive: false,
+ },
+ {
+ info: "text-wrap: balance; is active when line number is below threshold",
+ property: "text-wrap",
+ createTestElement: rootNode => {
+ const element = document.createElement("div");
+ element.textContent = LOREM_IPSUM;
+ rootNode.append(element);
+ return element;
+ },
+ tagName: "div",
+ rules: ["div { text-wrap: balance; width: 300px; }"],
+ isActive: true,
+ },
+ {
+ info: "text-wrap: balance is inactive when element is fragmented",
+ property: "text-wrap",
+ createTestElement: rootNode => {
+ const element = document.createElement("div");
+ element.textContent = `
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Donec a diam lectus. Sed sit amet ipsum mauris.
+ Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit.
+ `;
+ rootNode.append(element);
+ return element;
+ },
+ tagName: "div",
+ rules: ["div { text-wrap: balance; column-count: 2; }"],
+ isActive: false,
+ },
+ {
+ info: "text-wrap: balance; does not throw if element is not a block",
+ property: "text-wrap",
+ createTestElement: rootNode => {
+ const element = document.createElement("div");
+ element.textContent = LOREM_IPSUM;
+ rootNode.append(element);
+ return element;
+ },
+ tagName: "div",
+ rules: ["div { text-wrap: balance; display: inline; }"],
+ isActive: true,
+ },
+ {
+ info: "text-wrap: initial; is active",
+ property: "text-wrap",
+ createTestElement: rootNode => {
+ const element = document.createElement("div");
+ element.textContent = `
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Donec a diam lectus. Sed sit amet ipsum mauris.
+ Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit.
+ `;
+ rootNode.append(element);
+ return element;
+ },
+ tagName: "div",
+ rules: ["div { text-wrap: initial; width: 100px; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/vertical-align.mjs b/devtools/server/tests/chrome/inactive-property-helper/vertical-align.mjs
new file mode 100644
index 0000000000..e9873d4865
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/vertical-align.mjs
@@ -0,0 +1,56 @@
+/* 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/. */
+
+// InactivePropertyHelper `vertical-align` test cases.
+export default [
+ {
+ info: "vertical-align is inactive on a block element",
+ property: "vertical-align",
+ tagName: "div",
+ rules: ["div { vertical-align: top; }"],
+ isActive: false,
+ },
+ {
+ info: "vertical-align is inactive on a span with display block",
+ property: "vertical-align",
+ tagName: "span",
+ rules: ["span { vertical-align: top; display: block;}"],
+ isActive: false,
+ },
+ {
+ info: "vertical-align is active on a div with display inline-block",
+ property: "vertical-align",
+ tagName: "div",
+ rules: ["div { vertical-align: top; display: inline-block;}"],
+ isActive: true,
+ },
+ {
+ info: "vertical-align is active on a table-cell",
+ property: "vertical-align",
+ tagName: "div",
+ rules: ["div { vertical-align: top; display: table-cell;}"],
+ isActive: true,
+ },
+ {
+ info: "vertical-align is active on a block element ::first-letter",
+ property: "vertical-align",
+ tagName: "div",
+ rules: ["div::first-letter { vertical-align: top; }"],
+ isActive: true,
+ },
+ {
+ info: "vertical-align is active on a block element ::first-line",
+ property: "vertical-align",
+ tagName: "div",
+ rules: ["div::first-line { vertical-align: top; }"],
+ isActive: true,
+ },
+ {
+ info: "vertical-align is active on an inline element",
+ property: "vertical-align",
+ tagName: "span",
+ rules: ["span { vertical-align: top; }"],
+ isActive: true,
+ },
+];
diff --git a/devtools/server/tests/chrome/inactive-property-helper/width-height-ruby.mjs b/devtools/server/tests/chrome/inactive-property-helper/width-height-ruby.mjs
new file mode 100644
index 0000000000..0dda222e0b
--- /dev/null
+++ b/devtools/server/tests/chrome/inactive-property-helper/width-height-ruby.mjs
@@ -0,0 +1,147 @@
+/* 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/. */
+
+// InactivePropertyHelper `width & height on ruby element` test cases.
+export default [
+ {
+ info: "width is inactive on ruby element",
+ property: "width",
+ tagName: "ruby",
+ rules: ["ruby { width: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-width is inactive on ruby element",
+ property: "min-width",
+ tagName: "ruby",
+ rules: ["ruby { min-width: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-width is inactive on ruby element",
+ property: "max-width",
+ tagName: "ruby",
+ rules: ["ruby { max-width: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "height is inactive on ruby element",
+ property: "height",
+ tagName: "ruby",
+ rules: ["ruby { height: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "min-height is inactive on ruby element",
+ property: "min-height",
+ tagName: "ruby",
+ rules: ["ruby { min-height: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "max-height is inactive on ruby element",
+ property: "max-height",
+ tagName: "ruby",
+ rules: ["ruby { max-height: 10px; }"],
+ isActive: false,
+ },
+ {
+ info: "width is active on div element",
+ property: "width",
+ tagName: "div",
+ rules: ["div { width: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "min-width is active on div element",
+ property: "min-width",
+ tagName: "div",
+ rules: ["div { min-width: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "max-width is active on div element",
+ property: "max-width",
+ tagName: "div",
+ rules: ["div { max-width: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "height is active on div element",
+ property: "height",
+ tagName: "div",
+ rules: ["div { height: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "min-height is active on div element",
+ property: "min-height",
+ tagName: "div",
+ rules: ["div { min-height: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "max-height is active on div element",
+ property: "max-height",
+ tagName: "div",
+ rules: ["div { max-height: 10px; }"],
+ isActive: true,
+ },
+ {
+ info: "width is inactive on div element with display ruby",
+ property: "width",
+ tagName: "div",
+ rules: ["div { width: 10px; display: ruby;}"],
+ isActive: false,
+ },
+ {
+ info: "height is inactive on div element with display ruby",
+ property: "height",
+ tagName: "div",
+ rules: ["div { height: 10px; display: ruby;}"],
+ isActive: false,
+ },
+ {
+ info: "width is active on ruby element with display block",
+ property: "width",
+ tagName: "ruby",
+ rules: ["ruby { width: 10px; display: block;}"],
+ isActive: true,
+ },
+ {
+ info: "height is active on ruby element with display block",
+ property: "height",
+ tagName: "ruby",
+ rules: ["ruby { height: 10px; display: block;}"],
+ isActive: true,
+ },
+ {
+ info: "width is inactive on ruby-text element",
+ property: "width",
+ tagName: "rt",
+ rules: ["rt { width: 10px;}"],
+ isActive: false,
+ },
+ {
+ info: "height is inactive on ruby-text element",
+ property: "height",
+ tagName: "rt",
+ rules: ["rt { height: 10px;}"],
+ isActive: false,
+ },
+ {
+ info: "width is inactive on div elements with display ruby-text",
+ property: "width",
+ tagName: "div",
+ rules: ["div { width: 10px; display: ruby-text;}"],
+ isActive: false,
+ },
+ {
+ info: "height is inactive on div elements with display ruby-text",
+ property: "height",
+ tagName: "div",
+ rules: ["div { height: 10px; display: ruby-text;}"],
+ isActive: false,
+ },
+];
diff --git a/devtools/server/tests/chrome/inspector-delay-image-response.sjs b/devtools/server/tests/chrome/inspector-delay-image-response.sjs
new file mode 100644
index 0000000000..633d7e3aa6
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-delay-image-response.sjs
@@ -0,0 +1,46 @@
+/**
+ * Adapted from https://searchfox.org/mozilla-central/source/layout/reftests/backgrounds/delay-image-response.sjs
+ */
+"use strict";
+
+// A 1x1 PNG image.
+// Source: https://commons.wikimedia.org/wiki/File:1x1.png (Public Domain)
+const IMAGE = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+);
+
+// To avoid GC.
+let timer = null;
+
+function handleRequest(request, response) {
+ const query = {};
+ request.queryString.split("&").forEach(function (val) {
+ const [name, value] = val.split("=");
+ query[name] = unescape(value);
+ });
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "image/png", false);
+
+ // If there is no delay, we write the image and leave.
+ if (!("delay" in query)) {
+ response.write(IMAGE);
+ return;
+ }
+
+ // If there is a delay, we create a timer which, when it fires, will write
+ // image and leave.
+ response.processAsync();
+ const nsITimer = Ci.nsITimer;
+
+ timer = Cc["@mozilla.org/timer;1"].createInstance(nsITimer);
+ timer.initWithCallback(
+ function () {
+ response.write(IMAGE);
+ response.finish();
+ },
+ query.delay,
+ nsITimer.TYPE_ONE_SHOT
+ );
+}
diff --git a/devtools/server/tests/chrome/inspector-eyedropper.html b/devtools/server/tests/chrome/inspector-eyedropper.html
new file mode 100644
index 0000000000..f5bd3a1cb8
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-eyedropper.html
@@ -0,0 +1,20 @@
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Inspector Eyedropper tests</title>
+ <style>
+ html {
+ background: black;
+ }
+ </style>
+ <script type="text/javascript">
+ "use strict";
+
+ window.onload = function() {
+ window.opener.postMessage("ready", "*");
+ };
+ </script>
+</head>
+</body>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector-helpers.js b/devtools/server/tests/chrome/inspector-helpers.js
new file mode 100644
index 0000000000..0b7edd8035
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-helpers.js
@@ -0,0 +1,133 @@
+/* exported attachURL, promiseDone,
+ promiseOnce,
+ addTest, addAsyncTest,
+ runNextTest, _documentWalker */
+"use strict";
+
+const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+);
+const {
+ CommandsFactory,
+} = require("resource://devtools/shared/commands/commands-factory.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+);
+const {
+ DocumentWalker: _documentWalker,
+} = require("resource://devtools/server/actors/inspector/document-walker.js");
+
+// Always log packets when running tests.
+Services.prefs.setBoolPref("devtools.debugger.log", true);
+SimpleTest.registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("devtools.debugger.log");
+});
+
+if (!DevToolsServer.initialized) {
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+ SimpleTest.registerCleanupFunction(function () {
+ DevToolsServer.destroy();
+ });
+}
+
+var gAttachCleanups = [];
+
+SimpleTest.registerCleanupFunction(function () {
+ for (const cleanup of gAttachCleanups) {
+ cleanup();
+ }
+});
+
+/**
+ * Open a tab, load the url, wait for it to signal its readiness,
+ * connect to this tab via DevTools protocol and return.
+ *
+ * Returns an object with a few helpful attributes:
+ * - commands {Object}: The commands object defined by modules from devtools/shared/commands
+ * - target {TargetFront}: The current top-level target front.
+ * - doc {HtmlDocument}: the tab's document that got opened
+ */
+async function attachURL(url) {
+ // Get the current browser window
+ const gBrowser =
+ Services.wm.getMostRecentWindow("navigator:browser").gBrowser;
+
+ // open the url in a new tab, save a reference to the new inner window global object
+ // and wait for it to load. The tests rely on this window object to send a "ready"
+ // event to its opener (the test page). This window reference is used within
+ // the test tab, to reference the webpage being tested against, which is in another
+ // tab.
+ const windowOpened = BrowserTestUtils.waitForNewTab(gBrowser, url);
+ const win = window.open(url, "_blank");
+ await windowOpened;
+
+ const commands = await CommandsFactory.forTab(gBrowser.selectedTab);
+ await commands.targetCommand.startListening();
+
+ const cleanup = async function () {
+ await commands.destroy();
+ if (win) {
+ win.close();
+ }
+ };
+
+ gAttachCleanups.push(cleanup);
+ return {
+ commands,
+ target: commands.targetCommand.targetFront,
+ doc: win.document,
+ };
+}
+
+function promiseOnce(target, event) {
+ return new Promise(resolve => {
+ target.on(event, (...args) => {
+ if (args.length === 1) {
+ resolve(args[0]);
+ } else {
+ resolve(args);
+ }
+ });
+ });
+}
+
+function promiseDone(currentPromise) {
+ currentPromise.catch(err => {
+ ok(false, "Promise failed: " + err);
+ if (err.stack) {
+ dump(err.stack);
+ }
+ SimpleTest.finish();
+ });
+}
+
+var _tests = [];
+function addTest(test) {
+ _tests.push(test);
+}
+
+function addAsyncTest(generator) {
+ _tests.push(() => generator().catch(ok.bind(null, false)));
+}
+
+function runNextTest() {
+ if (!_tests.length) {
+ SimpleTest.finish();
+ return;
+ }
+ const fn = _tests.shift();
+ try {
+ fn();
+ } catch (ex) {
+ info(
+ "Test function " +
+ (fn.name ? "'" + fn.name + "' " : "") +
+ "threw an exception: " +
+ ex
+ );
+ }
+}
diff --git a/devtools/server/tests/chrome/inspector-search-data.html b/devtools/server/tests/chrome/inspector-search-data.html
new file mode 100644
index 0000000000..784dcb7c9b
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-search-data.html
@@ -0,0 +1,54 @@
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Inspector Search Test Data</title>
+ <style>
+ #pseudo {
+ display: block;
+ margin: 0;
+ }
+ #pseudo:before {
+ content: "before element";
+ }
+ #pseudo:after {
+ content: "after element";
+ }
+ </style>
+ <script type="text/javascript">
+ "use strict";
+
+ window.onload = function() {
+ window.opener.postMessage("ready", "*");
+ };
+ </script>
+</head>
+</body>
+ <!-- A comment
+ spread across multiple lines -->
+
+ <img width="100" height="100" src="large-image.jpg" />
+
+ <h1 id="pseudo">Heading 1</h1>
+ <p>A p tag with the text 'h1' inside of it.
+ <strong>A strong h1 result</strong>
+ </p>
+
+ <div id="arrows" northwest="↖" northeast="↗" southeast="↘" southwest="↙">
+ Unicode arrows
+ </div>
+
+ <h2>Heading 2</h2>
+ <h2>Heading 2</h2>
+ <h2>Heading 2</h2>
+
+ <h3>Heading 3</h3>
+ <h3>Heading 3</h3>
+ <h3>Heading 3</h3>
+
+ <h4>Heading 4</h4>
+ <h4>Heading 4</h4>
+ <h4>Heading 4</h4>
+
+ <div class="💩" id="💩" 💩="💩"></div>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector-styles-data.css b/devtools/server/tests/chrome/inspector-styles-data.css
new file mode 100644
index 0000000000..5c3652f522
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-styles-data.css
@@ -0,0 +1,3 @@
+.external-rule {
+ cursor: crosshair;
+}
diff --git a/devtools/server/tests/chrome/inspector-styles-data.html b/devtools/server/tests/chrome/inspector-styles-data.html
new file mode 100644
index 0000000000..334b268bfd
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-styles-data.html
@@ -0,0 +1,85 @@
+<html>
+<script>
+ "use strict";
+
+ window.onload = () => {
+ window.opener.postMessage("ready", "*");
+ };
+</script>
+<style>
+ .inheritable-rule {
+ font-size: 15px;
+ }
+ /* Has to be on one line, is such for test */
+ .column-rule { font-size: 20px; } .column-rule { font-size: 25px; }
+ .uninheritable-rule {
+ background-color: #f06;
+ }
+ @media screen {
+ #mediaqueried {
+ background-color: #f06;
+ }
+ }
+ #svgcontent rect {
+ fill: rgb(1,2,3);
+ }
+
+ #layout-element,
+ #layout-auto-margin-element {
+ width: 50px;
+ height: 50px;
+ padding: 3px 5px 7px 5px;
+ border: 5px solid red;
+ margin: 10px 20px 30px 0;
+ box-sizing: border-box;
+ position: absolute;
+ z-index: 2;
+ }
+
+ #layout-auto-margin-element {
+ margin: 10px auto;
+ }
+</style>
+<link type="text/css" rel="stylesheet" href="inspector-styles-data.css"></link>
+<body>
+ <h1>Style Actor Tests</h1>
+ <!-- Inheritance checks -->
+ <div id="inheritable-rule-uninheritable-style" class="inheritable-rule" style="background-color: purple">
+ <div id="inheritable-rule-inheritable-style" class="inheritable-rule" style="color: blue">
+ <div id="uninheritable-rule-uninheritable-style" class="uninheritable-rule" style="background-color: green">
+ <div id="uninheritable-rule-inheritable-style" class="uninheritable-rule" style="color: red">
+ <div id="test-node">
+ Here is the test node.
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Computed checks -->
+ <div id="computed-parent" class="external-rule inheritable-rule uninheritable-rule" style="color: red;">
+ <div id="computed-test-node" class="external-rule">
+ Here is the test node.
+ </div>
+ </div>
+
+ <!-- Matched checks -->
+ <div id="matched-parent" class="external-rule inheritable-rule column-rule uninheritable-rule" style="color: red;">
+ <div id="matched-test-node" style="font-size: 10px" class="external-rule">
+ Here is the test node.
+ </div>
+ </div>
+
+ <div id="mediaqueried">
+ Screen mediaqueried.
+ </div>
+
+ <div id="svgcontent">
+ <svg><rect></rect></svg>
+ </div>
+
+ <div id="layout-element">I can has layout</div>
+ <div id="layout-auto-margin-element">I can has layout too</div>
+
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector-template.html b/devtools/server/tests/chrome/inspector-template.html
new file mode 100644
index 0000000000..13c9d5c7d3
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-template.html
@@ -0,0 +1,17 @@
+<html>
+<body>
+ <template>
+ <p>template content</p>
+ </template>
+ <div></div>
+ <script>
+ "use strict";
+
+ const template = document.querySelector("template");
+ const clone = document.importNode(template.content, true);
+ document.querySelector("div").appendChild(clone);
+
+ window.opener.postMessage("ready", "*");
+ </script>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector-traversal-data.html b/devtools/server/tests/chrome/inspector-traversal-data.html
new file mode 100644
index 0000000000..e294796467
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector-traversal-data.html
@@ -0,0 +1,103 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Inspector Traversal Test Data</title>
+ <style type="text/css">
+ #pseudo::before {
+ content: "before";
+ }
+ #pseudo::after {
+ content: "after";
+ }
+ #pseudo-empty::before {
+ content: "before an empty element";
+ }
+ #shadow::before {
+ content: "Testing ::before on a shadow host";
+ }
+ </style>
+ <script type="text/javascript">
+ "use strict";
+
+ window.onload = function() {
+ // Set up a basic shadow DOM
+ const host = document.querySelector("#shadow");
+ if (host.attachShadow) {
+ const root = host.attachShadow({ mode: "open" });
+
+ const h3 = document.createElement("h3");
+ h3.append("Shadow ");
+
+ const em = document.createElement("em");
+ em.append("DOM");
+
+ const select = document.createElement("select");
+ select.setAttribute("multiple", "");
+ h3.appendChild(em);
+ root.appendChild(h3);
+ root.appendChild(select);
+ }
+
+ // Put a copy of the body in an iframe to test frame traversal.
+ const body = document.querySelector("body");
+ const data = "data:text/html,<html>" + body.outerHTML + "<html>";
+ const iframe = document.createElement("iframe");
+ iframe.setAttribute("id", "childFrame");
+ iframe.onload = function() {
+ window.opener.postMessage("ready", "*");
+ };
+ iframe.src = data;
+ body.appendChild(iframe);
+ };
+ </script>
+</head>
+<body style="background-color:white">
+ <h1>Inspector Actor Tests</h1>
+ <span id="longstring">longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong</span>
+ <span id="shortstring">short</span>
+ <span id="empty"></span>
+ <div id="longlist" data-test="exists">
+ <div id="a">a</div>
+ <div id="b">b</div>
+ <div id="c">c</div>
+ <div id="d">d</div>
+ <div id="e">e</div>
+ <div id="f">f</div>
+ <div id="g">g</div>
+ <div id="h">h</div>
+ <div id="i">i</div>
+ <div id="j">j</div>
+ <div id="k">k</div>
+ <div id="l">l</div>
+ <div id="m">m</div>
+ <div id="n">n</div>
+ <div id="o">o</div>
+ <div id="p">p</div>
+ <div id="q">q</div>
+ <div id="r">r</div>
+ <div id="s">s</div>
+ <div id="t">t</div>
+ <div id="u">u</div>
+ <div id="v">v</div>
+ <div id="w">w</div>
+ <div id="x">x</div>
+ <div id="y">y</div>
+ <div id="z">z</div>
+ </div>
+ <div id="longlist-sibling">
+ <div id="longlist-sibling-firstchild"></div>
+ </div>
+ <p id="edit-html"></p>
+
+ <select multiple><option>one</option><option>two</option></select>
+ <div id="pseudo"><span>middle</span></div>
+ <div id="pseudo-empty"></div>
+ <div id="shadow">light dom</div>
+ <object>
+ <div id="1"></div>
+ </object>
+ <div class="node-to-duplicate"></div>
+ <div id="scroll-into-view" style="margin-top: 1000px;">scroll</div>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector_css-properties.html b/devtools/server/tests/chrome/inspector_css-properties.html
new file mode 100644
index 0000000000..8cc6368cd1
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector_css-properties.html
@@ -0,0 +1,12 @@
+<html>
+<head>
+<body>
+ <script type="text/javascript">
+ "use strict";
+
+ window.onload = function() {
+ window.opener.postMessage("ready", "*");
+ };
+ </script>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector_display-type.html b/devtools/server/tests/chrome/inspector_display-type.html
new file mode 100644
index 0000000000..7bd0da6709
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector_display-type.html
@@ -0,0 +1,17 @@
+<html>
+<head>
+<body>
+ <div id="inline-block" style="display: inline-block">
+ HELLO WORLD
+ </div>
+ <div id="grid" style="display: grid"></div>
+ <div id="block" style="position: fixed"></div>
+ <script>
+ "use strict";
+
+ window.onload = () => {
+ window.opener.postMessage("ready", "*");
+ };
+ </script>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector_getImageData.html b/devtools/server/tests/chrome/inspector_getImageData.html
new file mode 100644
index 0000000000..754798df44
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector_getImageData.html
@@ -0,0 +1,23 @@
+<html>
+<head>
+<body>
+ <img class="custom">
+ <img class="big-horizontal" src="large-image.jpg" style="width:500px;">
+ <canvas class="big-vertical" style="width:500px;"></canvas>
+ <img class="small" src="small-image.gif">
+ <img class="data" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=">
+ <script>
+ "use strict";
+
+ window.onload = () => {
+ const canvas = document.querySelector("canvas"), ctx = canvas.getContext("2d");
+ canvas.width = 1000;
+ canvas.height = 2000;
+ ctx.fillStyle = "red";
+ ctx.fillRect(0, 0, 1000, 2000);
+
+ window.opener.postMessage("ready", "*");
+ };
+ </script>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/inspector_getOffsetParent.html b/devtools/server/tests/chrome/inspector_getOffsetParent.html
new file mode 100644
index 0000000000..72aac5f70b
--- /dev/null
+++ b/devtools/server/tests/chrome/inspector_getOffsetParent.html
@@ -0,0 +1,18 @@
+<html>
+<head>
+<body>
+ <div id="relative_parent" style="position: relative">
+ <div id="absolute_child" style="position: absolute"></div>
+ </div>
+ <div id="static"></div>
+ <div id="no_parent" style="position: absolute"></div>
+ <div id="fixed" style="position: fixed"></div>
+ <script>
+ "use strict";
+
+ window.onload = () => {
+ window.opener.postMessage("ready", "*");
+ };
+ </script>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/large-image.jpg b/devtools/server/tests/chrome/large-image.jpg
new file mode 100644
index 0000000000..bda383e594
--- /dev/null
+++ b/devtools/server/tests/chrome/large-image.jpg
Binary files differ
diff --git a/devtools/server/tests/chrome/memory-helpers.js b/devtools/server/tests/chrome/memory-helpers.js
new file mode 100644
index 0000000000..e4db689134
--- /dev/null
+++ b/devtools/server/tests/chrome/memory-helpers.js
@@ -0,0 +1,72 @@
+/* exported Task, startServerAndGetSelectedTabMemory, destroyServerAndFinish,
+ waitForTime, waitUntil */
+"use strict";
+
+const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+);
+const {
+ CommandsFactory,
+} = require("resource://devtools/shared/commands/commands-factory.js");
+
+// Always log packets when running tests.
+Services.prefs.setBoolPref("devtools.debugger.log", true);
+var gReduceTimePrecision = Services.prefs.getBoolPref(
+ "privacy.reduceTimerPrecision"
+);
+Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false);
+SimpleTest.registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("devtools.debugger.log");
+ Services.prefs.setBoolPref(
+ "privacy.reduceTimerPrecision",
+ gReduceTimePrecision
+ );
+});
+
+async function getTargetForSelectedTab() {
+ const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+ const commands = await CommandsFactory.forTab(
+ browserWindow.gBrowser.selectedTab
+ );
+ await commands.targetCommand.startListening();
+ const isEveryFrameTargetEnabled = Services.prefs.getBoolPref(
+ "devtools.every-frame-target.enabled",
+ false
+ );
+ if (!isEveryFrameTargetEnabled) {
+ return commands.targetCommand.targetFront;
+ }
+
+ // If EFT is enabled, we need to retrieve the target of the test document
+ const targets = await commands.targetCommand.getAllTargets([
+ commands.targetCommand.TYPES.FRAME,
+ ]);
+
+ return targets.find(t => t.url !== "chrome://mochikit/content/harness.xhtml");
+}
+
+async function startServerAndGetSelectedTabMemory() {
+ const target = await getTargetForSelectedTab();
+ const memory = await target.getFront("memory");
+ return { memory, target };
+}
+
+async function destroyServerAndFinish(target) {
+ await target.destroy();
+ SimpleTest.finish();
+}
+
+function waitForTime(ms) {
+ return new Promise((resolve, reject) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+function waitUntil(predicate) {
+ if (predicate()) {
+ return Promise.resolve(true);
+ }
+ return new Promise(resolve =>
+ setTimeout(() => waitUntil(predicate).then(() => resolve(true)), 10)
+ );
+}
diff --git a/devtools/server/tests/chrome/nonchrome_unsafeDereference.html b/devtools/server/tests/chrome/nonchrome_unsafeDereference.html
new file mode 100644
index 0000000000..15e9fd9160
--- /dev/null
+++ b/devtools/server/tests/chrome/nonchrome_unsafeDereference.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<script>
+"use strict";
+
+var xhr = new XMLHttpRequest();
+xhr.timeout = 1742;
+xhr.expando = "Expando!";
+</script>
+</html>
diff --git a/devtools/server/tests/chrome/small-image.gif b/devtools/server/tests/chrome/small-image.gif
new file mode 100644
index 0000000000..e702427a53
--- /dev/null
+++ b/devtools/server/tests/chrome/small-image.gif
Binary files differ
diff --git a/devtools/server/tests/chrome/suspendTimeouts_content.html b/devtools/server/tests/chrome/suspendTimeouts_content.html
new file mode 100644
index 0000000000..f3969fc10c
--- /dev/null
+++ b/devtools/server/tests/chrome/suspendTimeouts_content.html
@@ -0,0 +1 @@
+<script src='suspendTimeouts_content.js'></script>
diff --git a/devtools/server/tests/chrome/suspendTimeouts_content.js b/devtools/server/tests/chrome/suspendTimeouts_content.js
new file mode 100644
index 0000000000..cb41653cff
--- /dev/null
+++ b/devtools/server/tests/chrome/suspendTimeouts_content.js
@@ -0,0 +1,75 @@
+"use strict";
+
+// To make it easier to follow, this code is arranged so that the functions are
+// arranged in the order they are called.
+
+const worker = new Worker("suspendTimeouts_worker.js");
+worker.onerror = error => {
+ const message = `error from worker: ${error.filename}:${error.lineno}: ${error.message}`;
+ throw new Error(message);
+};
+
+// Create a message channel. Send one end to the worker, and return the other to
+// the mochitest.
+/* exported create_channel */
+function create_channel() {
+ const { port1, port2 } = new MessageChannel();
+ info(`sending port to worker`);
+ worker.postMessage({ mochitestPort: port1 }, [port1]);
+ return port2;
+}
+
+// Provoke the worker into sending us a message, and then refuse to receive said
+// message, causing it to be delayed for later delivery.
+//
+// The worker will also post a message to the MessagePort we sent it earlier.
+// That message should not be delayed, as it is handled by the mochitest window,
+// not the content window. Its receipt signals that the test can assume that the
+// runnable for step 2) is in the main thread's event queue, so the test can
+// prepare for step 3).
+/* exported start_worker */
+function start_worker() {
+ worker.onmessage = handle_echo;
+
+ // This should prevent worker.onmessage from being called, until
+ // resumeTimeouts is called.
+ //
+ // This function is provided by test_suspendTimeouts.js.
+ // eslint-disable-next-line no-undef
+ suspendTimeouts();
+
+ // The worker should echo this message back to us and to the mochitest.
+ worker.postMessage("HALLOOOOOO"); // suitable message for echoing
+ info(`posted message to worker`);
+}
+
+var resumeTimeouts_has_returned = false;
+
+// Resume timeouts. After this call, the worker's message should not be
+// delivered to our onmessage handler until control returns to the event loop.
+/* exported resume_timeouts */
+function resume_timeouts() {
+ // This function is provided by test_suspendTimeouts.js.
+ // eslint-disable-next-line no-undef
+ resumeTimeouts(); // onmessage handlers should not run from this call.
+
+ resumeTimeouts_has_returned = true;
+
+ // When this JavaScript invocation returns to the main thread's event loop,
+ // only then should onmessage handlers be invoked.
+}
+
+// The buggy code calls this handler from the resumeTimeouts call, before the
+// main thread returns to the event loop. The correct code calls this only once
+// the JavaScript invocation that called resumeTimeouts has run to completion.
+function handle_echo({ data }) {
+ ok(
+ resumeTimeouts_has_returned,
+ "worker message delivered from main event loop"
+ );
+
+ // Finish the mochitest.
+ // This function is set and defined by test_suspendTimeouts.js
+ // eslint-disable-next-line no-undef
+ finish();
+}
diff --git a/devtools/server/tests/chrome/suspendTimeouts_worker.js b/devtools/server/tests/chrome/suspendTimeouts_worker.js
new file mode 100644
index 0000000000..e008f7d0d3
--- /dev/null
+++ b/devtools/server/tests/chrome/suspendTimeouts_worker.js
@@ -0,0 +1,12 @@
+"use strict";
+
+// Once content sends us a port connected to the mochitest, we simply echo every
+// message we receive back to content and the mochitest.
+onmessage = ({ data: { mochitestPort } }) => {
+ onmessage = ({ data }) => {
+ // Send a message to both content and the mochitest, which the main thread's
+ // event loop will attempt to deliver as step 2).
+ postMessage(`worker echo to content: ${data}`);
+ mochitestPort.postMessage(`worker echo to port: ${data}`);
+ };
+};
diff --git a/devtools/server/tests/chrome/test_Debugger.Script.prototype.global.html b/devtools/server/tests/chrome/test_Debugger.Script.prototype.global.html
new file mode 100644
index 0000000000..d403d6b4a3
--- /dev/null
+++ b/devtools/server/tests/chrome/test_Debugger.Script.prototype.global.html
@@ -0,0 +1,48 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=958646
+
+Debugger.Script.prototype.global should return innerize globals, not WindowProxies.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Debugger.Script.prototype.global should return inner windows</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+const {addDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs");
+addDebuggerToGlobal(globalThis);
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const iframe = document.createElement("iframe");
+ iframe.src = "data:text/html,<script>function glorp() { }<\/script>";
+ iframe.onload = firstOnLoadHandler;
+ document.body.appendChild(iframe);
+
+ function firstOnLoadHandler() {
+ const dbg = new Debugger();
+ const iframeDO = dbg.addDebuggee(iframe.contentWindow);
+
+ // For sanity: check that the debuggee global is the inner window,
+ // and that the outer window gets a distinct D.O.
+ const iframeWindowProxyDO = iframeDO.makeDebuggeeValue(iframe.contentWindow);
+ ok(iframeDO !== iframeWindowProxyDO);
+
+ // The real test: Debugger.Script.prototype.global returns inner windows.
+ ok(iframeDO.getOwnPropertyDescriptor("glorp").value.script.global === iframeDO);
+
+ SimpleTest.finish();
+ }
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_Debugger.Source.prototype.elementAttribute.html b/devtools/server/tests/chrome/test_Debugger.Source.prototype.elementAttribute.html
new file mode 100644
index 0000000000..cb9c2bbcdc
--- /dev/null
+++ b/devtools/server/tests/chrome/test_Debugger.Source.prototype.elementAttribute.html
@@ -0,0 +1,159 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=941876
+
+Debugger.Source.prototype.element and .elementAttributeName should report the DOM
+element to which code is attached (if any), and how.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Debugger.Source.prototype.element should return owning element</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+const {addSandboxedDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs");
+addSandboxedDebuggerToGlobal(globalThis);
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ let log = "";
+ let doc, dieter, ulrich, isolde, albrecht;
+ let dbg, iframeDO;
+
+ // Create an iframe to debug.
+ // We can't use a data: URL here, because we want to test script elements
+ // that refer to the JavaScript via 'src' attributes, and data: documents
+ // can't refer to those. So we use a separate HTML document.
+ const iframe = document.createElement("iframe");
+ iframe.src = "Debugger.Source.prototype.element.html";
+ iframe.onload = onLoadHandler;
+ document.body.appendChild(iframe);
+
+ function onLoadHandler() {
+ log += "l";
+
+ // Now that the iframe's window has been created, we can add
+ // it as a debuggee.
+ dbg = new Debugger();
+ dbg.onDebuggerStatement = franzDebuggerHandler;
+ iframeDO = dbg.addDebuggee(iframe.contentWindow);
+ iframeDO.makeDebuggeeValue.bind(iframeDO);
+
+ // Send a click event to heidi.
+ doc = iframe.contentWindow.document;
+ doc.getElementById("heidi").dispatchEvent(new Event("click"));
+ }
+
+ function franzDebuggerHandler(frame) {
+ log += "f";
+
+ // The top stack frame should be franz, belonging to the script element.
+ ok(frame.callee.displayName === "franz", "top frame is franz");
+ ok(frame.script.source.elementAttributeName === undefined,
+ "top frame source doesn't belong to an attribute");
+
+ // The second stack frame should belong to heinrich.
+ ok(frame.older.script.source.elementAttributeName === undefined,
+ "second frame source doesn't belong to an attribute");
+
+ // The next stack frame should belong to heidi's onclick handler.
+ ok(frame.older.older.script.source.elementAttributeName === "onclick",
+ "third frame source belongs to 'onclick' attribute");
+
+ // Try a dynamically inserted inline script element.
+ ulrich = doc.createElement("script");
+ ulrich.text = "debugger;";
+ dbg.onDebuggerStatement = ulrichDebuggerHandler;
+ doc.body.appendChild(ulrich);
+ }
+
+ function ulrichDebuggerHandler(frame) {
+ log += "u";
+
+ // The top frame should be ulrich's text.
+ ok(frame.script.source.elementAttributeName === undefined,
+ "top frame is not on an attribute of ulrich");
+
+ // Try a dynamically inserted out-of-line script element.
+ isolde = doc.createElement("script");
+ isolde.setAttribute("src", "Debugger.Source.prototype.element-2.js");
+ isolde.setAttribute("id", "idolde, my dear");
+ dbg.onDebuggerStatement = isoldeDebuggerHandler;
+ doc.body.appendChild(isolde);
+ }
+
+ function isoldeDebuggerHandler(frame) {
+ log += "i";
+
+ ok(frame.script.source.elementAttributeName === undefined,
+ "top frame source is not an attribute of isolde");
+ info("frame.script.source.elementAttributeName is: " +
+ uneval(frame.script.source.elementAttributeName));
+
+ // Try a dynamically created div element with a handler.
+ dieter = doc.createElement("div");
+ dieter.setAttribute("id", "dieter");
+ dieter.setAttribute("ondrag", "debugger;");
+ dbg.onDebuggerStatement = dieterDebuggerHandler;
+ dieter.dispatchEvent(new Event("drag"));
+ }
+
+ function dieterDebuggerHandler(frame) {
+ log += "d";
+
+ // The top frame should belong to dieter's ondrag handler.
+ ok(frame.script.source.elementAttributeName === "ondrag",
+ "second event's handler is on dieter's 'ondrag' element");
+
+ // Try sending an 'onresize' event to the window.
+ //
+ // Note that we only want Debugger to see the events we send, not any
+ // genuine resize events accidentally generated by the test harness (see bug
+ // 1162067). So we mark our events as cancelable; that seems to be the only
+ // bit chrome can fiddle on an Event that content code will see and that
+ // won't affect propagation. Then, the content event only runs its
+ // 'debugger' statement when the event is cancelable. It's a kludge.
+ dbg.onDebuggerStatement = resizeDebuggerHandler;
+ iframe.contentWindow.dispatchEvent(new Event("resize", { cancelable: true }));
+ }
+
+ function resizeDebuggerHandler(frame) {
+ log += "e";
+
+ // The top frame should belong to the body's 'onresize' handler, even
+ // though we sent the message to the window and it was handled.
+ ok(frame.script.source.elementAttributeName === "onresize",
+ "onresize event handler is on body element's 'onresize' attribute");
+
+ // In SVG, the event and the attribute that holds that event's handler
+ // have different names. Debugger.Source.prototype.elementAttributeName
+ // should report (as one might infer) the attribute name, not the event
+ // name.
+ albrecht = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
+ albrecht.setAttribute("onload", "debugger;");
+ dbg.onDebuggerStatement = SVGLoadHandler;
+ albrecht.dispatchEvent(new Event("SVGLoad"));
+ }
+
+ function SVGLoadHandler(frame) {
+ log += "s";
+
+ // The top frame's source should be on albrecht's 'onload' attribute.
+ ok(frame.script.source.elementAttributeName === "onload",
+ "SVGLoad event handler is on albrecht's 'onload' attribute");
+
+ ok(log === "lfuides", "all tests actually ran");
+ SimpleTest.finish();
+ }
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionScript.html b/devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionScript.html
new file mode 100644
index 0000000000..09c23b5253
--- /dev/null
+++ b/devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionScript.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=969786
+
+Debugger.Source.prototype.introductionScript and .introductionOffset should
+behave when 'eval' is called with no scripted frames active at all.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Debugger.Source.prototype.introductionScript with no caller</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+const {addDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs");
+addDebuggerToGlobal(globalThis);
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ let dbg, iframeDO, doc;
+
+ // Create an iframe to debug.
+ const iframe = document.createElement("iframe");
+ iframe.src = "data:text/html,<div>Hi!</div>";
+ iframe.onload = onLoadHandler;
+ document.body.appendChild(iframe);
+
+ function onLoadHandler() {
+ // Now that the iframe's window has been created, we can add
+ // it as a debuggee.
+ dbg = new Debugger();
+ iframeDO = dbg.addDebuggee(iframe.contentWindow);
+
+ doc = iframe.contentWindow.document;
+ const script = doc.createElement("script");
+ script.text = "setTimeout(eval.bind(null, 'debugger;'), 0);";
+ dbg.onDebuggerStatement = timerHandler;
+ doc.body.appendChild(script);
+ }
+
+ function timerHandler(frame) {
+ // The top stack frame's source should have an undefined
+ // introduction script and introduction offset.
+ const source = frame.script.source;
+ ok(source.introductionScript === undefined,
+ "setTimeout eval introductionScript is undefined");
+ ok(source.introductionOffset === undefined,
+ "setTimeout eval introductionOffset is undefined");
+
+ // Check that the above isn't just some quirk of iframes, or the
+ // browser milieu destroying information: an eval script should indeed
+ // have proper introduction information.
+ const script2 = doc.createElement("script");
+ script2.text = "eval('debugger;');";
+ iframeDO.makeDebuggeeValue(script2);
+
+ dbg.onDebuggerStatement = evalHandler;
+ doc.body.appendChild(script2);
+ }
+
+ function evalHandler(frame) {
+ // The top stack frame's source should be introduced by the script that
+ // called eval.
+ const source = frame.script.source;
+ const frame2 = frame.older;
+ const frame3 = frame2.older;
+
+ ok(source.introductionType === "eval",
+ "top frame's source was introduced by 'eval'");
+ ok(source.introductionScript === frame2.script,
+ "eval frame's introduction script is the older frame's script");
+ ok(source.introductionOffset === frame2.offset,
+ "eval frame's introduction offset is current offset in older frame");
+
+ // The frame that called eval, in turn, was introduced at the call that
+ // inserted the script element into the document.
+ ok(frame2.script.source.introductionType === "injectedScript",
+ "older frame has no introduction type");
+ ok(frame2.script.source.introductionScript === frame3.script,
+ "older frame has introduction script");
+ ok(frame2.script.source.introductionOffset === frame3.offset,
+ "older frame has introduction offset");
+
+ SimpleTest.finish();
+ }
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionType.html b/devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionType.html
new file mode 100644
index 0000000000..1057d4b94f
--- /dev/null
+++ b/devtools/server/tests/chrome/test_Debugger.Source.prototype.introductionType.html
@@ -0,0 +1,159 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=935203
+
+Debugger.Source.prototype.introductionType should return 'eventHandler' for
+JavaScrip appearing in an inline event handler attribute.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Debugger.Source.prototype.introductionType should identify event handlers</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+const {addSandboxedDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs");
+addSandboxedDebuggerToGlobal(globalThis);
+
+let dbg;
+let iframeDO, doc;
+let Tootles;
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+addTest(function setup() {
+ // Create an iframe to debug.
+ const iframe = document.createElement("iframe");
+ iframe.srcdoc = "<div id='Tootles' onclick='debugger;'>I'm a DIV!</div>" +
+ "<script id='Auddie'>function auddie() { debugger; }<\/script>";
+ iframe.onload = onLoadHandler;
+ document.body.appendChild(iframe);
+
+ function onLoadHandler() {
+ // Now that the iframe's window has been created, we can add
+ // it as a debuggee.
+ dbg = new Debugger();
+ iframeDO = dbg.addDebuggee(iframe.contentWindow);
+ doc = iframe.contentWindow.document;
+ Tootles = doc.getElementById("Tootles");
+ iframeDO.makeDebuggeeValue(Tootles);
+
+ runNextTest();
+ }
+});
+
+// Check the introduction type of in-markup event handler code.
+// Send a click event to Tootles, whose handler has a 'debugger' statement,
+// and check that script's introduction type.
+addTest(function ClickOnTootles() {
+ dbg.onDebuggerStatement = TootlesClickDebugger;
+ Tootles.dispatchEvent(new Event("click"));
+
+ function TootlesClickDebugger(frame) {
+ // some sanity checks
+ is(frame.script.source.elementAttributeName, "onclick",
+ "top frame source belongs to 'onclick' attribute");
+
+ // And, the actual point of this test:
+ is(frame.script.source.introductionType, "eventHandler",
+ "top frame source's introductionType is 'eventHandler'");
+
+ runNextTest();
+ }
+});
+
+// Check the introduction type of dynamically added event handler code.
+// Add a drag event handler to Tootles as a string, and then send
+// Tootles a drag event.
+addTest(function DragTootles() {
+ dbg.onDebuggerStatement = TootlesDragDebugger;
+ Tootles.setAttribute("ondrag", "debugger;");
+ Tootles.dispatchEvent(new Event("drag"));
+
+ function TootlesDragDebugger(frame) {
+ // sanity checks
+ is(frame.script.source.elementAttributeName, "ondrag",
+ "top frame source belongs to 'ondrag' attribute");
+
+ // And, the actual point of this test:
+ is(frame.script.source.introductionType, "eventHandler",
+ "top frame source's introductionType is 'eventHandler'");
+
+ runNextTest();
+ }
+});
+
+// Check the introduction type of an in-markup script element.
+addTest(function checkAuddie() {
+ const fnDO = iframeDO.getOwnPropertyDescriptor("auddie").value;
+ iframeDO.makeDebuggeeValue(doc.getElementById("Auddie"));
+
+ is(fnDO.class, "Function",
+ "Script element 'Auddie' defined function 'auddie'.");
+ is(fnDO.script.source.elementAttributeName, undefined,
+ "Function auddie's script doesn't belong to any attribute of 'Auddie'");
+ is(fnDO.script.source.introductionType, "inlineScript",
+ "Function auddie's script's source was introduced by a script element");
+
+ runNextTest();
+});
+
+// Check the introduction type of a dynamically inserted script element.
+addTest(function InsertRover() {
+ dbg.onDebuggerStatement = RoverDebugger;
+ const rover = doc.createElement("script");
+ rover.text = "debugger;";
+ doc.body.appendChild(rover);
+ iframeDO.makeDebuggeeValue(rover);
+
+ function RoverDebugger(frame) {
+ // sanity checks
+ ok(frame.script.source.elementAttributeName === undefined,
+ "Rover script doesn't belong to an attribute of Rover");
+
+ // Check the introduction type.
+ ok(frame.script.source.introductionType === "injectedScript",
+ "Rover script's introduction type is 'injectedScript'");
+
+ runNextTest();
+ }
+});
+
+// Creates a chrome document with a XUL script element, and check its introduction type.
+addTest(function XULDocumentScript() {
+ const frame = document.createElement("iframe");
+ frame.src = "doc_Debugger.Source.prototype.introductionType.xhtml";
+ frame.onload = docLoaded;
+ info("Appending iframe containing a document with a XUL script tag");
+ document.body.appendChild(frame);
+
+ function docLoaded() {
+ info("Loaded chrome document");
+ const xulFrameDO = dbg.addDebuggee(frame.contentWindow);
+ const xulFnDO = xulFrameDO.getOwnPropertyDescriptor("xulScriptFunc").value;
+ is(typeof xulFnDO, "object", "XUL script element defined 'xulScriptFunc'");
+ is(xulFnDO.class, "Function",
+ "XUL global 'xulScriptFunc' is indeed a function");
+
+ // A XUL script elements' code gets shared amongst all
+ // instantiations of the document, so there's no specific DOM element
+ // we can attribute the code to.
+
+ is(xulFnDO.script.source.introductionType, "inlineScript",
+ "xulScriptFunc's introduction type is 'inlineScript'");
+ runNextTest();
+ }
+});
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_animation-type-longhand.html b/devtools/server/tests/chrome/test_animation-type-longhand.html
new file mode 100644
index 0000000000..97f5b1e469
--- /dev/null
+++ b/devtools/server/tests/chrome/test_animation-type-longhand.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<meta charset="UTF-8">
+<title>Test animation-type-longhand</title>
+<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+<body>
+<script>
+ "use strict";
+
+ // This test checks the content of animation type for longhands table that
+ // * every longhand property is included
+ // * nothing else is included
+ // * no property is mapped to more than one animation type
+ window.onload = function() {
+ const {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ const { ANIMATION_TYPE_FOR_LONGHANDS } =
+ require("devtools/server/actors/animation-type-longhand");
+ const InspectorUtils = SpecialPowers.InspectorUtils;
+
+ const all_longhands = InspectorUtils.getCSSPropertyNames({
+ includeShorthands: false,
+ includeExperimentals: true,
+ });
+
+ const unseen_longhands = new Set(all_longhands);
+ const seen_longhands = new Set();
+ for (const [, names] of ANIMATION_TYPE_FOR_LONGHANDS) {
+ for (const name of names) {
+ ok(!seen_longhands.has(name),
+ `${name} should have only one animation type`);
+ ok(unseen_longhands.has(name),
+ `${name} is an unseen longhand property`);
+ unseen_longhands.delete(name);
+ seen_longhands.add(name);
+ }
+ }
+ is(unseen_longhands.size, 0,
+ "All longhands should be mapped to some animation type: " + [...unseen_longhands].join(", "));
+
+ SimpleTest.finish();
+ };
+</script>
+</body>
diff --git a/devtools/server/tests/chrome/test_css-logic-specificity.html b/devtools/server/tests/chrome/test_css-logic-specificity.html
new file mode 100644
index 0000000000..b5d5c76c0c
--- /dev/null
+++ b/devtools/server/tests/chrome/test_css-logic-specificity.html
@@ -0,0 +1,84 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that css-logic calculates CSS specificity properly
+-->
+<meta charset="utf-8">
+<title>Test css-logic specificity</title>
+<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+<body style="background:blue;">
+<script>
+ "use strict";
+
+ window.onload = function() {
+ const {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ const {CssLogic, CssSelector} = require("devtools/server/actors/inspector/css-logic");
+
+ const TEST_DATA = [
+ {text: "*", expected: 0},
+ {text: "LI", expected: 1},
+ {text: "UL LI", expected: 2},
+ {text: "UL OL + LI", expected: 3},
+ {text: "H1 + [REL=\"up\"]", expected: 1025},
+ {text: "UL OL LI.red", expected: 1027},
+ {text: "LI.red.level", expected: 2049},
+ {text: ".red .level", expected: 2048},
+ {text: "#x34y", expected: 1048576},
+ {text: "#s12:not(FOO)", expected: 1048577},
+ {text: "body#home div#warning p.message", expected: 2098179},
+ {text: "* body#home div#warning p.message", expected: 2098179},
+ {text: "#footer :not(nav) li", expected: 1048578},
+ {text: "bar:nth-child(n)", expected: 1025},
+ {text: "li::marker", expected: 2},
+ {text: "a:hover", expected: 1025},
+ ];
+
+ function createDocument() {
+ let text = TEST_DATA.map(i=>i.text).join(",");
+ text = '<style>' + text + " {color:red;}</style>";
+ document.body.innerHTML = text;
+ }
+
+ function getExpectedSpecificity(selectorText) {
+ return TEST_DATA.filter(i => i.text === selectorText)[0].expected;
+ }
+
+ SimpleTest.waitForExplicitFinish();
+
+ createDocument();
+ const cssLogic = new CssLogic();
+
+ cssLogic.highlight(document.body);
+
+ // There could be more stylesheets due to e.g, accessiblecaret, so find the
+ // right one.
+ info(`Sheets: ${cssLogic.sheets.map(s => s.href).join(", ")}`);
+
+ const cssSheet = cssLogic.sheets.find(s => s.href == location.href);
+ const cssRule = cssSheet.domSheet.cssRules[0];
+ const selectors = CssLogic.getSelectors(cssRule);
+
+ is(selectors.length, TEST_DATA.length, "Should be the right rule");
+
+ info("Iterating over the test selectors: " + selectors.join(", "));
+ for (let i = 0; i < selectors.length; i++) {
+ const selectorText = selectors[i];
+ info("Testing selector " + selectorText);
+
+ const selector = new CssSelector(cssRule, selectorText, i);
+ const expected = getExpectedSpecificity(selectorText);
+ const specificity = selector.cssRule.selectorSpecificityAt(selector.selectorIndex);
+ is(specificity, expected,
+ 'Selector "' + selectorText + '" has a specificity of ' + expected);
+ }
+
+ info("Testing specificity of element.style");
+ const colorProp = cssLogic.getPropertyInfo("background");
+ is(colorProp.matchedSelectors[0].specificity, 0x40000000,
+ "Element styles have specificity of 0x40000000 (1073741824).");
+
+ SimpleTest.finish();
+ };
+ </script>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_css-logic.html b/devtools/server/tests/chrome/test_css-logic.html
new file mode 100644
index 0000000000..6378f5a9e7
--- /dev/null
+++ b/devtools/server/tests/chrome/test_css-logic.html
@@ -0,0 +1,73 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+const {CssLogic} = require("devtools/server/actors/inspector/css-logic");
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+addTest(function getComputedStyle() {
+ const node = document.querySelector("#computed-style");
+ is(CssLogic.getComputedStyle(node).getPropertyValue("width"),
+ "50px", "Computed style on a normal node works (width)");
+ is(CssLogic.getComputedStyle(node).getPropertyValue("height"),
+ "10px", "Computed style on a normal node works (height)");
+
+ const firstChild = new _documentWalker(node, window).firstChild();
+ is(CssLogic.getComputedStyle(firstChild).getPropertyValue("content"),
+ "\"before\"", "Computed style on a ::before node works (content)");
+ const lastChild = new _documentWalker(node, window).lastChild();
+ is(CssLogic.getComputedStyle(lastChild).getPropertyValue("content"),
+ "\"after\"", "Computed style on a ::after node works (content)");
+
+ runNextTest();
+});
+
+addTest(function getBindingElementAndPseudo() {
+ const node = document.querySelector("#computed-style");
+ let {bindingElement, pseudo} = CssLogic.getBindingElementAndPseudo(node);
+
+ is(bindingElement, node,
+ "Binding element is the node itself for a normal node");
+ ok(!pseudo, "Pseudo is null for a normal node");
+
+ const firstChild = new _documentWalker(node, window).firstChild();
+ ({ bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(firstChild));
+ is(bindingElement, node,
+ "Binding element is the parent for a pseudo node");
+ is(pseudo, "::before", "Pseudo is correct for a ::before node");
+
+ const lastChild = new _documentWalker(node, window).lastChild();
+ ({ bindingElement, pseudo } = CssLogic.getBindingElementAndPseudo(lastChild));
+ is(bindingElement, node,
+ "Binding element is the parent for a pseudo node");
+ is(pseudo, "::after", "Pseudo is correct for a ::after node");
+
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+ <style type="text/css">
+ #computed-style { width: 50px; height: 10px; }
+ #computed-style::before { content: "before"; }
+ #computed-style::after { content: "after"; }
+ </style>
+ <div id="computed-style"></div>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_css-properties.html b/devtools/server/tests/chrome/test_css-properties.html
new file mode 100644
index 0000000000..3cbc3a4aa7
--- /dev/null
+++ b/devtools/server/tests/chrome/test_css-properties.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1265798 - Replace inIDOMUtils.cssPropertyIsShorthand
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test CSS Properties Actor</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ function toSortedString(array) {
+ return JSON.stringify(array.sort());
+ }
+
+ const runCssPropertiesTests = async function(url) {
+ info(`Opening tab with CssPropertiesActor support.`);
+ // Open a new tab. The only property we are interested in is `target`.
+ const { target } = await attachURL(url);
+ const { cssProperties } = await target.getFront("cssProperties");
+
+ ok(cssProperties.isKnown("border"),
+ "The `border` shorthand property is known.");
+ ok(cssProperties.isKnown("display"),
+ "The `display` property is known.");
+ ok(!cssProperties.isKnown("foobar"),
+ "A fake property is not known.");
+ ok(cssProperties.isKnown("--foobar"),
+ "A CSS variable properly evaluates.");
+ ok(cssProperties.isKnown("--foob\\{ar"),
+ "A CSS variable with escaped character properly evaluates.");
+ ok(cssProperties.isKnown("--fübar"),
+ "A CSS variable unicode properly evaluates.");
+ ok(!cssProperties.isKnown("--foo bar"),
+ "A CSS variable with spaces fails");
+
+ is(toSortedString(cssProperties.getValues("margin")),
+ toSortedString(["auto", "inherit", "initial", "unset", "revert", "revert-layer"]),
+ "Can get values for the CSS margin.");
+ is(cssProperties.getValues("foobar").length, 0,
+ "Unknown values return an empty array.");
+
+ const bgColorValues = cssProperties.getValues("background-color");
+ ok(bgColorValues.includes("blanchedalmond"),
+ "A property with color values includes blanchedalmond.");
+ ok(bgColorValues.includes("papayawhip"),
+ "A property with color values includes papayawhip.");
+ ok(bgColorValues.includes("rgb"),
+ "A property with color values includes non-colors.");
+ };
+
+ addAsyncTest(async function setup() {
+ const url = document.getElementById("cssProperties").href;
+ await runCssPropertiesTests(url);
+
+ runNextTest();
+ });
+
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+ </script>
+</head>
+<body>
+ <a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1265798">Mozilla Bug 1265798</a>
+ <a id="cssProperties" target="_blank" href="inspector_css-properties.html">Test Document</a>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_device.html b/devtools/server/tests/chrome/test_device.html
new file mode 100644
index 0000000000..117e50b5ca
--- /dev/null
+++ b/devtools/server/tests/chrome/test_device.html
@@ -0,0 +1,79 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 895360 - [app manager] Device meta data actor
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Mozilla Bug</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+window.onload = function() {
+ const {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ const {DevToolsClient} = require("devtools/client/devtools-client");
+ const {DevToolsServer} = require("devtools/server/devtools-server");
+
+ SimpleTest.waitForExplicitFinish();
+
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ client.connect().then(function onConnect() {
+ return client.mainRoot.getFront("device");
+ }).then(function(d) {
+ let desc;
+ const appInfo = Services.appinfo;
+ const utils = window.windowUtils;
+
+ const localDesc = {
+ appid: appInfo.ID,
+ vendor: appInfo.vendor,
+ name: appInfo.name,
+ version: appInfo.version,
+ appbuildid: appInfo.appBuildID,
+ platformbuildid: appInfo.platformBuildID,
+ platformversion: appInfo.platformVersion,
+ geckobuildid: appInfo.platformBuildID,
+ geckoversion: appInfo.platformVersion,
+ useragent: window.navigator.userAgent,
+ locale: Services.locale.appLocaleAsBCP47,
+ os: appInfo.OS,
+ processor: appInfo.XPCOMABI.split("-")[0],
+ compiler: appInfo.XPCOMABI.split("-")[1],
+ dpi: utils.displayDPI,
+ width: window.screen.width,
+ height: window.screen.height,
+ };
+
+ function checkValues() {
+ for (const key in localDesc) {
+ is(desc[key], localDesc[key], "valid field (" + key + ")");
+ }
+
+ const currProfD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ const profileDir = currProfD.path;
+ ok(profileDir.includes(!!desc.profile.length && desc.profile),
+ "valid profile name");
+
+ client.close().then(() => {
+ DevToolsServer.destroy();
+ SimpleTest.finish();
+ });
+ }
+
+ d.getDescription().then(function(v) {
+ desc = v;
+ }).then(checkValues);
+ });
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_executeInGlobal-outerized_this.html b/devtools/server/tests/chrome/test_executeInGlobal-outerized_this.html
new file mode 100644
index 0000000000..6a846596b2
--- /dev/null
+++ b/devtools/server/tests/chrome/test_executeInGlobal-outerized_this.html
@@ -0,0 +1,73 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=837060
+
+When we use Debugger.Object.prototype.executeInGlobal, the 'this' value seen
+by the evaluated code should be the WindowProxy, not the inner window
+object.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Mozilla Bug 837060</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+const {addDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs");
+addDebuggerToGlobal(globalThis);
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const iframe = document.createElement("iframe");
+ iframe.src = "data:text/html,<script>var me = 'page 1';<\/script>";
+ iframe.onload = firstOnLoadHandler;
+ document.body.appendChild(iframe);
+
+ function firstOnLoadHandler() {
+ const dbg = new Debugger();
+ const page1DO = dbg.addDebuggee(iframe.contentWindow);
+ iframe.src = "data:text/html,<script>var me = 'page 2';<\/script>";
+ iframe.onload = function() {
+ const page2DO = dbg.addDebuggee(iframe.contentWindow);
+ ok(page1DO !== page2DO, "the two pages' globals get distinct D.O's");
+ ok(page1DO.unsafeDereference() === page2DO.unsafeDereference(),
+ "unwrapping page1DO and page2DO outerizes both, yielding the same outer window");
+
+ is(page1DO.executeInGlobal("me").return,
+ "page 1", "page1DO continues to refer to original page");
+ is(page2DO.executeInGlobal("me").return, "page 2",
+ "page2DO refers to current page");
+
+ is(page1DO.executeInGlobal("this === window").return, true,
+ "page 1: Debugger.Object.prototype.executeInGlobal should outerize 'this'");
+ is(page1DO.executeInGlobalWithBindings("this === window", {x: 2}).return, true,
+ "page 1: Debugger.Object.prototype.executeInGlobal should outerize 'this'");
+
+ is(page2DO.executeInGlobal("this === window").return, true,
+ "page 2: Debugger.Object.prototype.executeInGlobal should outerize 'this'");
+ is(page2DO.executeInGlobalWithBindings("this === window", {x: 2}).return, true,
+ "page 2: Debugger.Object.prototype.executeInGlobal should outerize 'this'");
+
+ // Debugger doesn't let one use outer windows as globals. You have to innerize.
+ const outerDO = page1DO.makeDebuggeeValue(page1DO.unsafeDereference());
+ ok(outerDO !== page1DO,
+ "outer window gets its own D.O, distinct from page 1's global");
+ ok(outerDO !== page2DO,
+ "outer window gets its own D.O, distinct from page 2's global");
+ SimpleTest.doesThrow(() => outerDO.executeInGlobal("me"),
+ "outer window D.Os can't be used as globals");
+
+ SimpleTest.finish();
+ };
+ }
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_highlighter_paused_debugger.html b/devtools/server/tests/chrome/test_highlighter_paused_debugger.html
new file mode 100644
index 0000000000..82dc939dd6
--- /dev/null
+++ b/devtools/server/tests/chrome/test_highlighter_paused_debugger.html
@@ -0,0 +1,88 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the PausedDebuggerOverlay highlighter.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>PausedDebuggerOverlay test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ require("devtools/server/actors/inspector/inspector");
+ const {HighlighterEnvironment} = require("devtools/server/actors/highlighters");
+ const {PausedDebuggerOverlay} = require("devtools/server/actors/highlighters/paused-debugger");
+
+ const env = new HighlighterEnvironment();
+ env.initFromWindow(window);
+
+ const highlighter = new PausedDebuggerOverlay(env);
+ await highlighter.isReady;
+ const anonymousContent = highlighter.markup.content;
+
+ const id = elementID => `${highlighter.ID_CLASS_PREFIX}${elementID}`;
+
+ function isHidden(elementID) {
+ const attr = anonymousContent.root.getElementById(id(elementID)).getAttribute("hidden");
+ return typeof attr === "string" && attr == "true";
+ }
+
+ function getReason() {
+ return anonymousContent.root.getElementById(id("reason")).textContent;
+ }
+
+ function isOverlayShown() {
+ const attr = anonymousContent.root.getElementById(id("root")).getAttribute("overlay");
+ return typeof attr === "string" && attr == "true";
+ }
+
+ info("Test that the various elements with IDs exist");
+ ok(highlighter.getElement("root"), "The root wrapper element exists");
+ ok(highlighter.getElement("toolbar"), "The toolbar element exists");
+ ok(highlighter.getElement("reason"), "The reason label element exists");
+
+ info("Test that the highlighter is hidden by default");
+ ok(isHidden("root"), "The highlighter is hidden");
+
+ info("Show the highlighter with overlay and toolbar");
+ let didShow = highlighter.show("breakpoint");
+ ok(didShow, "Calling show returned true");
+ ok(!isHidden("root"), "The highlighter is shown");
+ ok(isOverlayShown(), "The overlay is shown");
+ is(
+ getReason(),
+ "Paused on breakpoint",
+ "The reason displayed in the toolbar is correct"
+ );
+
+ info("Call show again with another reason");
+ didShow = highlighter.show("debuggerStatement");
+ ok(didShow, "Calling show returned true too");
+ ok(!isHidden("root"), "The highlighter is still shown");
+ is(getReason(), "Paused on debugger statement",
+ "The reason displayed in the toolbar is correct again");
+ ok(isOverlayShown(), "The overlay is still shown too");
+
+ info("Call show again but with no reason");
+ highlighter.show();
+ ok(isOverlayShown(), "The overlay is shown however");
+
+ info("Hide the highlighter");
+ highlighter.hide();
+ ok(isHidden("root"), "The highlighter is now hidden");
+
+ SimpleTest.finish();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-changeattrs.html b/devtools/server/tests/chrome/test_inspector-changeattrs.html
new file mode 100644
index 0000000000..94c4c3dc1b
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-changeattrs.html
@@ -0,0 +1,90 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gInspectee = null;
+let gWalker = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ gInspectee = doc;
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(function testChangeAttrs() {
+ const attrNode = gInspectee.querySelector("#a");
+ let attrFront;
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(front => {
+ attrFront = front;
+ dump("attrFront is: " + attrFront + "\n");
+ // Add a few attributes.
+ const list = attrFront.startModifyingAttributes();
+ list.setAttribute("data-newattr", "newvalue");
+ list.setAttribute("data-newattr2", "newvalue");
+ return list.apply();
+ }).then(() => {
+ // We're only going to test that the change hit the document.
+ // There are other tests that make sure changes are propagated
+ // to the client.
+ is(attrNode.getAttribute("data-newattr"), "newvalue",
+ "Node should have the first new attribute");
+ is(attrNode.getAttribute("data-newattr2"), "newvalue",
+ "Node should have the second new attribute.");
+ }).then(() => {
+ // Change an attribute.
+ const list = attrFront.startModifyingAttributes();
+ list.setAttribute("data-newattr", "changedvalue");
+ return list.apply();
+ }).then(() => {
+ is(attrNode.getAttribute("data-newattr"), "changedvalue",
+ "Node should have the changed first value.");
+ is(attrNode.getAttribute("data-newattr2"), "newvalue",
+ "Second value should remain unchanged.");
+ }).then(() => {
+ const list = attrFront.startModifyingAttributes();
+ list.removeAttribute("data-newattr2");
+ return list.apply();
+ }).then(() => {
+ is(attrNode.getAttribute("data-newattr"), "changedvalue",
+ "Node should have the changed first value.");
+ ok(!attrNode.hasAttribute("data-newattr2"), "Second value should be removed.");
+ }).then(runNextTest));
+});
+
+addTest(function cleanup() {
+ gWalker = null;
+ gInspectee = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-changevalue.html b/devtools/server/tests/chrome/test_inspector-changevalue.html
new file mode 100644
index 0000000000..f5aee52881
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-changevalue.html
@@ -0,0 +1,68 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gInspectee = null;
+let gWalker = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ gInspectee = doc;
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(function testChangeValue() {
+ const contentNode = gInspectee.querySelector("#a").firstChild;
+ let nodeFront;
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(front => {
+ // Get the text child
+ return gWalker.children(front, { maxNodes: 1 });
+ }).then(children => {
+ nodeFront = children.nodes[0];
+ is(nodeFront.nodeType, Node.TEXT_NODE);
+ return nodeFront.setNodeValue("newvalue");
+ }).then(() => {
+ // We're only going to test that the change hit the document.
+ // There are other tests that make sure changes are propagated
+ // to the client.
+ is(contentNode.nodeValue, "newvalue", "Node should have a new value.");
+ }).then(runNextTest));
+});
+
+addTest(function cleanup() {
+ gWalker = null;
+ gInspectee = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-display-type.html b/devtools/server/tests/chrome/test_inspector-display-type.html
new file mode 100644
index 0000000000..a8bbedc22a
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-display-type.html
@@ -0,0 +1,81 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1431900
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1431900</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+var gWalker;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addAsyncTest(async function testInlineBlockDisplayType() {
+ info("Test getting the display type of an inline block element.");
+ const node = await gWalker.querySelector(gWalker.rootNode, "#inline-block");
+ const displayType = node.displayType;
+ is(displayType, "inline-block", "The node has a display type of 'inline-block'.");
+ runNextTest();
+});
+
+addAsyncTest(async function testInlineTextChildDisplayType() {
+ info("Test getting the display type of an inline text child.");
+ const node = await gWalker.querySelector(gWalker.rootNode, "#inline-block");
+ const children = await gWalker.children(node);
+ const inlineTextChild = children.nodes[0];
+ const displayType = inlineTextChild.displayType;
+ ok(!displayType, "No display type for inline text child.");
+ runNextTest();
+});
+
+addAsyncTest(async function testGridDisplayType() {
+ info("Test getting the display type of an grid container.");
+ const node = await gWalker.querySelector(gWalker.rootNode, "#grid");
+ const displayType = node.displayType;
+ is(displayType, "grid", "The node has a display type of 'grid'.");
+ runNextTest();
+});
+
+addAsyncTest(async function testBlockDisplayType() {
+ info("Test getting the display type of a block element.");
+ const node = await gWalker.querySelector(gWalker.rootNode, "#block");
+ const displayType = await node.displayType;
+ is(displayType, "block", "The node has a display type of 'block'.");
+ runNextTest();
+});
+
+addTest(function() {
+ gWalker = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1431900">Mozilla Bug 1431900</a>
+<a id="inspectorContent" target="_blank" href="inspector_display-type.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-duplicate-node.html b/devtools/server/tests/chrome/test_inspector-duplicate-node.html
new file mode 100644
index 0000000000..205e11629e
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-duplicate-node.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1208864
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1208864</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(async function testDuplicateNode() {
+ const className = ".node-to-duplicate";
+ let matches = await gWalker.querySelectorAll(gWalker.rootNode, className);
+ is(matches.length, 1, "There should initially be one node to duplicate.");
+
+ const nodeFront = await gWalker.querySelector(gWalker.rootNode, className);
+ await gWalker.duplicateNode(nodeFront);
+
+ matches = await gWalker.querySelectorAll(gWalker.rootNode, className);
+ is(matches.length, 2, "The node should now be duplicated.");
+
+ runNextTest();
+});
+
+addTest(function cleanup() {
+ gWalker = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1208864">Mozilla Bug 1208864</a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-hide.html b/devtools/server/tests/chrome/test_inspector-hide.html
new file mode 100644
index 0000000000..e699400ee0
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-hide.html
@@ -0,0 +1,71 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+let gInspectee = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gInspectee = doc;
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(function testRearrange() {
+ let listFront = null;
+ const listNode = gInspectee.querySelector("#longlist");
+
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#longlist").then(front => {
+ listFront = front;
+ }).then(() => {
+ const computed = gInspectee.defaultView.getComputedStyle(listNode);
+ is(computed.visibility, "visible", "Node should be visible to start with");
+ return gWalker.hideNode(listFront);
+ }).then(response => {
+ const computed = gInspectee.defaultView.getComputedStyle(listNode);
+ is(computed.visibility, "hidden", "Node should be hidden");
+ return gWalker.unhideNode(listFront);
+ }).then(() => {
+ const computed = gInspectee.defaultView.getComputedStyle(listNode);
+ is(computed.visibility, "visible", "Node should be visible again.");
+ }).then(runNextTest));
+});
+
+addTest(function cleanup() {
+ gWalker = null;
+ gInspectee = null;
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-inactive-property-helper.html b/devtools/server/tests/chrome/test_inspector-inactive-property-helper.html
new file mode 100644
index 0000000000..86c783c035
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-inactive-property-helper.html
@@ -0,0 +1,124 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Test for InactivePropertyHelper</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript">
+"use strict";
+SimpleTest.waitForExplicitFinish();
+
+(async function() {
+ const { require } = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ const { isPropertyUsed } = require("devtools/server/actors/utils/inactive-property-helper");
+
+ const INACTIVE_CSS_PREF = "devtools.inspector.inactive.css.enabled";
+ const CUSTOM_HIGHLIGHT_API = "dom.customHighlightAPI.enabled";
+ const TEXT_WRAP_BALANCE = "layout.css.text-wrap-balance.enabled";
+
+ Services.prefs.setBoolPref(INACTIVE_CSS_PREF, true);
+ Services.prefs.setBoolPref(CUSTOM_HIGHLIGHT_API, true);
+ Services.prefs.setBoolPref(TEXT_WRAP_BALANCE, true);
+
+ SimpleTest.registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(INACTIVE_CSS_PREF);
+ Services.prefs.clearUserPref(CUSTOM_HIGHLIGHT_API);
+ Services.prefs.clearUserPref(TEXT_WRAP_BALANCE);
+ });
+
+ const FOLDER = "./inactive-property-helper";
+
+ // Each file should `export default` an array of objects, representing each test case.
+ // A single test case is an object of the following shape:
+ // - {String} info: a summary of the test case
+ // - {String} property: the CSS property that should be tested
+ // - {String|undefined} tagName: the tagName of the element we're going to test.
+ // Optional only if there's a createTestElement property.
+ // - {Function|undefined} createTestElement: A function that takes a node as a parameter
+ // where elements used for the test case will
+ // be appended. The function should return the
+ // element that will be passed to
+ // isPropertyUsed.
+ // Optional only if there's a tagName property
+ // - {Array<String>} rules: An array of the rules that will be applied on the element.
+ // This can't be empty as isPropertyUsed need a rule.
+ // - {Integer|undefined} ruleIndex: If there are multiples rules in `rules`, the index
+ // of the one that should be tested in isPropertyUsed.
+ // - {Boolean} isActive: should the property be active (isPropertyUsed `used` result).
+ const testFiles = [
+ "align-content.mjs",
+ "border-image.mjs",
+ "cue-pseudo-element.mjs",
+ "first-letter-pseudo-element.mjs",
+ "first-line-pseudo-element.mjs",
+ "flex-grid-item-properties.mjs",
+ "float.mjs",
+ "gap.mjs",
+ "grid-container-properties.mjs",
+ "grid-with-absolute-properties.mjs",
+ "multicol-container-properties.mjs",
+ "highlight-pseudo-elements.mjs",
+ "margin-padding.mjs",
+ "max-min-width-height.mjs",
+ "place-items-content.mjs",
+ "placeholder-pseudo-element.mjs",
+ "positioned.mjs",
+ "scroll-padding.mjs",
+ "vertical-align.mjs",
+ "table.mjs",
+ "table-cell.mjs",
+ "text-overflow.mjs",
+ "text-wrap.mjs",
+ "width-height-ruby.mjs",
+ ].map(file => `${FOLDER}/${file}`);
+
+ // Import all the test cases
+ const tests =
+ (await Promise.all(testFiles.map(f => import(f).then(data => data.default)))).flat();
+
+ for (const {
+ info: summary,
+ property,
+ tagName,
+ createTestElement,
+ rules,
+ ruleIndex,
+ isActive,
+ expectedMsgId,
+ } of tests) {
+ // Create an element which will contain the test elements.
+ const main = document.createElement("main");
+ document.firstElementChild.appendChild(main);
+
+ // Apply the CSS rules to the document.
+ const style = document.createElement("style");
+ main.append(style);
+ for (const dataRule of rules) {
+ style.sheet.insertRule(dataRule);
+ }
+ const rule = style.sheet.cssRules[ruleIndex || 0];
+
+ // Create the test elements
+ let el;
+ if (createTestElement) {
+ el = createTestElement(main);
+ } else {
+ el = document.createElement(tagName);
+ main.append(el);
+ }
+
+ const { used, msgId } = isPropertyUsed(el, getComputedStyle(el), rule, property);
+ ok(used === isActive, summary);
+ if (expectedMsgId) {
+ is(msgId, expectedMsgId, `${summary} - returned expected msgId`);
+ }
+
+ main.remove();
+ }
+ SimpleTest.finish();
+})();
+ </script>
+ </head>
+ <body></body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-mutations-attr.html b/devtools/server/tests/chrome/test_inspector-mutations-attr.html
new file mode 100644
index 0000000000..9430db65bd
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-mutations-attr.html
@@ -0,0 +1,169 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gInspectee = null;
+let gWalker = null;
+let attrNode;
+let attrFront;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gInspectee = doc;
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(setupAttrTest);
+addTest(testAddAttribute);
+addTest(testChangeAttribute);
+addTest(testRemoveAttribute);
+addTest(testQueuedMutations);
+addTest(setupFrameAttrTest);
+addTest(testAddAttribute);
+addTest(testChangeAttribute);
+addTest(testRemoveAttribute);
+addTest(testQueuedMutations);
+
+function setupAttrTest() {
+ attrNode = gInspectee.querySelector("#a");
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#a").then(node => {
+ attrFront = node;
+ }).then(runNextTest));
+}
+
+function setupFrameAttrTest() {
+ const frame = gInspectee.querySelector("#childFrame");
+ attrNode = frame.contentDocument.querySelector("#a");
+
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#childFrame").then(childFrame => {
+ return childFrame.walkerFront.children(childFrame);
+ }).then(children => {
+ const nodes = children.nodes;
+ is(nodes.length, 1, "There should be only one child of the iframe");
+ const [iframeNode] = nodes;
+ is(iframeNode.nodeType, Node.DOCUMENT_NODE, "iframe child should be a document node");
+ return iframeNode.walkerFront.querySelector(iframeNode, "#a");
+ }).then(node => {
+ attrFront = node;
+ }).then(runNextTest));
+}
+
+function testAddAttribute() {
+ attrNode.setAttribute("data-newattr", "newvalue");
+ attrNode.setAttribute("data-newattr2", "newvalue");
+ attrFront.walkerFront.once("mutations", () => {
+ is(attrFront.attributes.length, 3, "Should have id and two new attributes.");
+ is(attrFront.getAttribute("data-newattr"), "newvalue",
+ "Node front should have the first new attribute");
+ is(attrFront.getAttribute("data-newattr2"), "newvalue",
+ "Node front should have the second new attribute.");
+ runNextTest();
+ });
+}
+
+function testChangeAttribute() {
+ attrNode.setAttribute("data-newattr", "changedvalue1");
+ attrNode.setAttribute("data-newattr", "changedvalue2");
+ attrNode.setAttribute("data-newattr", "changedvalue3");
+ attrFront.walkerFront.once("mutations", mutations => {
+ is(mutations.length, 1,
+ "Only one mutation is sent for multiple queued attribute changes");
+ is(attrFront.attributes.length, 3, "Should have id and two new attributes.");
+ is(attrFront.getAttribute("data-newattr"), "changedvalue3",
+ "Node front should have the changed first value");
+ is(attrFront.getAttribute("data-newattr2"), "newvalue",
+ "Second value should remain unchanged.");
+ runNextTest();
+ });
+}
+
+function testRemoveAttribute() {
+ attrNode.removeAttribute("data-newattr2");
+ attrFront.walkerFront.once("mutations", () => {
+ is(attrFront.attributes.length, 2, "Should have id and one remaining attribute.");
+ is(attrFront.getAttribute("data-newattr"), "changedvalue3",
+ "Node front should still have the first value");
+ ok(!attrFront.hasAttribute("data-newattr2"), "Second value should be removed.");
+ runNextTest();
+ });
+}
+
+function testQueuedMutations() {
+ // All modifications to each attribute should be queued in one mutation event.
+
+ attrNode.removeAttribute("data-newattr");
+ attrNode.setAttribute("data-newattr", "1");
+ attrNode.removeAttribute("data-newattr");
+ attrNode.setAttribute("data-newattr", "2");
+ attrNode.removeAttribute("data-newattr");
+
+ for (let i = 0; i <= 1000; i++) {
+ attrNode.setAttribute("data-newattr2", i);
+ }
+
+ attrNode.removeAttribute("data-newattr3");
+ attrNode.setAttribute("data-newattr3", "1");
+ attrNode.removeAttribute("data-newattr3");
+ attrNode.setAttribute("data-newattr3", "2");
+ attrNode.removeAttribute("data-newattr3");
+ attrNode.setAttribute("data-newattr3", "3");
+
+ // This shouldn't be added in the attribute set, since it's a new
+ // attribute that's been added and removed.
+ attrNode.setAttribute("data-newattr4", "4");
+ attrNode.removeAttribute("data-newattr4");
+
+ attrFront.walkerFront.once("mutations", mutations => {
+ is(mutations.length, 4,
+ "Only one mutation each is sent for multiple queued attribute changes");
+ is(attrFront.attributes.length, 3,
+ "Should have id, data-newattr2, and data-newattr3.");
+
+ is(attrFront.getAttribute("data-newattr2"), "1000",
+ "Node front should still have the correct value");
+ is(attrFront.getAttribute("data-newattr3"), "3",
+ "Node front should still have the correct value");
+ ok(!attrFront.hasAttribute("data-newattr"), "Attribute value should be removed.");
+ ok(!attrFront.hasAttribute("data-newattr4"), "Attribute value should be removed.");
+
+ runNextTest();
+ });
+}
+
+addTest(function cleanup() {
+ gInspectee = null;
+ gWalker = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-mutations-events.html b/devtools/server/tests/chrome/test_inspector-mutations-events.html
new file mode 100644
index 0000000000..b48952c4d9
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-mutations-events.html
@@ -0,0 +1,187 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1157469
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1157469</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const prevPrefValue = Services.prefs.getBoolPref("devtools.chrome.enabled");
+ Services.prefs.setBoolPref("devtools.chrome.enabled", true);
+
+ let inspectee = null;
+ let inspector = null;
+ let walker = null;
+ const eventListener1 = function() {};
+ const eventListener2 = function() {};
+ let eventNode1;
+ let eventNode2;
+ let eventFront1;
+ let eventFront2;
+
+ addAsyncTest(async function setup() {
+ info("Setting up inspector and walker actors.");
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ inspectee = doc;
+ inspector = await target.getFront("inspector");
+ walker = inspector.walker;
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function setupEventTest() {
+ eventNode1 = inspectee.querySelector("#a");
+ eventNode2 = inspectee.querySelector("#b");
+
+ eventFront1 = await walker.querySelector(walker.rootNode, "#a");
+ eventFront2 = await walker.querySelector(walker.rootNode, "#b");
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function testChangeEventListenerOnSingleNode() {
+ checkNodesHaveNoEventListener();
+
+ info("add event listener on a single node");
+ eventNode1.addEventListener("click", eventListener1);
+
+ let mutations = await waitForMutations();
+ is(mutations.length, 1, "one mutation expected");
+ is(mutations[0].target, eventFront1, "mutation targets eventFront1");
+ is(mutations[0].type, "events", "mutation type is events");
+ is(mutations[0].hasEventListeners, true,
+ "mutation target should have event listeners");
+ is(eventFront1.hasEventListeners, true, "eventFront1 should have event listeners");
+
+ info("remove event listener on a single node");
+ eventNode1.removeEventListener("click", eventListener1);
+
+ mutations = await waitForMutations();
+ is(mutations.length, 1, "one mutation expected");
+ is(mutations[0].target, eventFront1, "mutation targets eventFront1");
+ is(mutations[0].type, "events", "mutation type is events");
+ is(mutations[0].hasEventListeners, false,
+ "mutation target should have no event listeners");
+ is(eventFront1.hasEventListeners, false,
+ "eventFront1 should have no event listeners");
+
+ info("perform several event listener changes on a single node");
+ eventNode1.addEventListener("click", eventListener1);
+ eventNode1.addEventListener("click", eventListener2);
+ eventNode1.removeEventListener("click", eventListener1);
+ eventNode1.removeEventListener("click", eventListener2);
+
+ mutations = await waitForMutations();
+ is(mutations.length, 1, "one mutation expected");
+ is(mutations[0].target, eventFront1, "mutation targets eventFront1");
+ is(mutations[0].type, "events", "mutation type is events");
+ is(mutations[0].hasEventListeners, false,
+ "no event listener expected on mutation target");
+ is(eventFront1.hasEventListeners, false, "no event listener expected on node");
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function testChangeEventsOnSeveralNodes() {
+ checkNodesHaveNoEventListener();
+
+ info("add event listeners on both nodes");
+ eventNode1.addEventListener("click", eventListener1);
+ eventNode2.addEventListener("click", eventListener2);
+
+ let mutations = await waitForMutations();
+ is(mutations.length, 2, "two mutations expected, one for each modified node");
+ // first mutation
+ is(mutations[0].target, eventFront1, "first mutation targets eventFront1");
+ is(mutations[0].type, "events", "mutation type is events");
+ is(mutations[0].hasEventListeners, true,
+ "mutation target should have event listeners");
+ is(eventFront1.hasEventListeners, true, "eventFront1 should have event listeners");
+ // second mutation
+ is(mutations[1].target, eventFront2, "second mutation targets eventFront2");
+ is(mutations[1].type, "events", "mutation type is events");
+ is(mutations[1].hasEventListeners, true,
+ "mutation target should have event listeners");
+ is(eventFront2.hasEventListeners, true, "eventFront1 should have event listeners");
+
+ info("remove event listeners on both nodes");
+ eventNode1.removeEventListener("click", eventListener1);
+ eventNode2.removeEventListener("click", eventListener2);
+
+ mutations = await waitForMutations();
+ is(mutations.length, 2, "one mutation registered for event listener change");
+ // first mutation
+ is(mutations[0].target, eventFront1, "first mutation targets eventFront1");
+ is(mutations[0].type, "events", "mutation type is events");
+ is(mutations[0].hasEventListeners, false,
+ "mutation target should have no event listeners");
+ is(eventFront1.hasEventListeners, false,
+ "eventFront2 should have no event listeners");
+ // second mutation
+ is(mutations[1].target, eventFront2, "second mutation targets eventFront2");
+ is(mutations[1].type, "events", "mutation type is events");
+ is(mutations[1].hasEventListeners, false,
+ "mutation target should have no event listeners");
+ is(eventFront2.hasEventListeners, false,
+ "eventFront2 should have no event listeners");
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function testRemoveMissingEvent() {
+ checkNodesHaveNoEventListener();
+
+ info("try to remove an event listener not previously added");
+ eventNode1.removeEventListener("click", eventListener1);
+
+ info("set any attribute on the node to trigger a mutation");
+ eventNode1.setAttribute("data-attr", "somevalue");
+
+ const mutations = await waitForMutations();
+ is(mutations.length, 1, "expect only one mutation");
+ isnot(mutations.type, "events", "mutation type should not be events");
+
+ Services.prefs.setBoolPref("devtools.chrome.enabled", prevPrefValue);
+ runNextTest();
+ });
+
+ function checkNodesHaveNoEventListener() {
+ is(eventFront1.hasEventListeners, false,
+ "eventFront1 hasEventListeners should be false");
+ is(eventFront2.hasEventListeners, false,
+ "eventFront2 hasEventListeners should be false");
+ }
+
+ function waitForMutations() {
+ return new Promise(resolve => {
+ walker.once("mutations", mutations => {
+ resolve(mutations);
+ });
+ });
+ }
+
+ runNextTest();
+};
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1157469">Mozilla Bug 1157469</a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-mutations-value.html b/devtools/server/tests/chrome/test_inspector-mutations-value.html
new file mode 100644
index 0000000000..14e93b9d1c
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-mutations-value.html
@@ -0,0 +1,163 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+const WalkerActor = require("devtools/server/actors/inspector/walker");
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+const testSummaryLength = 10;
+WalkerActor.setValueSummaryLength(testSummaryLength);
+SimpleTest.registerCleanupFunction(function() {
+ WalkerActor.setValueSummaryLength(WalkerActor.DEFAULT_VALUE_SUMMARY_LENGTH);
+});
+
+let gInspectee = null;
+let gWalker = null;
+let valueNode;
+var valueFront;
+var longStringFront;
+var longString = "stringstringstringstringstringstringstringstringstringstringstring";
+var shortString = "str";
+var shortString2 = "str2";
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gInspectee = doc;
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(setupValueTest);
+addTest(testKeepLongValue);
+addTest(testSetShortValue);
+addTest(testKeepShortValue);
+addTest(testSetLongValue);
+addTest(setupFrameValueTest);
+addTest(testKeepLongValue);
+addTest(testSetShortValue);
+addTest(testKeepShortValue);
+addTest(testSetLongValue);
+
+function setupValueTest() {
+ valueNode = gInspectee.querySelector("#longstring").firstChild;
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#longstring").then(node => {
+ longStringFront = node;
+ return gWalker.children(node);
+ }).then(children => {
+ valueFront = children.nodes[0];
+ }).then(runNextTest));
+}
+
+function setupFrameValueTest() {
+ const frame = gInspectee.querySelector("#childFrame");
+ valueNode = frame.contentDocument.querySelector("#longstring").firstChild;
+
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#childFrame").then(childFrame => {
+ return gWalker.children(childFrame);
+ }).then(children => {
+ const nodes = children.nodes;
+ is(nodes.length, 1, "There should be only one child of the iframe");
+ const [node] =nodes;
+ is(node.nodeType, Node.DOCUMENT_NODE, "iframe child should be a document node");
+ return node.walkerFront.querySelector(node, "#longstring");
+ }).then(node => {
+ longStringFront = node;
+ return longStringFront.walkerFront.children(node);
+ }).then(children => {
+ valueFront = children.nodes[0];
+ }).then(runNextTest));
+}
+
+function checkNodeFrontValue(front, expectedValue) {
+ return front.getNodeValue().then(longstring => {
+ return longstring.string();
+ }).then(str => {
+ is(str, expectedValue, "Node value is as expected");
+ });
+}
+
+function testKeepLongValue() {
+ // After first setup we should have a long string in the node
+ ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined.");
+
+ valueNode.nodeValue = longString;
+ valueFront.walkerFront.once("mutations", (changes) => {
+ ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined.");
+ ok(!changes.some(change => change.type === "inlineTextChild"),
+ "No inline text child mutation was fired.");
+ checkNodeFrontValue(valueFront, longString).then(runNextTest);
+ });
+}
+
+function testSetShortValue() {
+ ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined.");
+
+ valueNode.nodeValue = shortString;
+ valueFront.walkerFront.once("mutations", (changes) => {
+ ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined.");
+ ok(changes.some(change => change.type === "inlineTextChild"),
+ "An inlineTextChild mutation was fired.");
+ checkNodeFrontValue(valueFront, shortString).then(runNextTest);
+ });
+}
+
+function testKeepShortValue() {
+ ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined.");
+
+ valueNode.nodeValue = shortString2;
+ valueFront.walkerFront.once("mutations", (changes) => {
+ ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined.");
+ ok(!changes.some(change => change.type === "inlineTextChild"),
+ "No inline text child mutation was fired.");
+ checkNodeFrontValue(valueFront, shortString2).then(runNextTest);
+ });
+}
+
+function testSetLongValue() {
+ ok(!!longStringFront.inlineTextChild, "Text node is short enough to be inlined.");
+
+ valueNode.nodeValue = longString;
+ valueFront.walkerFront.once("mutations", (changes) => {
+ ok(!longStringFront.inlineTextChild, "Text node is too long to be inlined.");
+ ok(changes.some(change => change.type === "inlineTextChild"),
+ "An inlineTextChild mutation was fired.");
+ checkNodeFrontValue(valueFront, longString).then(runNextTest);
+ });
+}
+
+addTest(function cleanup() {
+ gInspectee = null;
+ gWalker = null;
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-pick-color.html b/devtools/server/tests/chrome/test_inspector-pick-color.html
new file mode 100644
index 0000000000..74aa3c50ce
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-pick-color.html
@@ -0,0 +1,94 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the inspector actor has the pickColorFromPage and cancelPickColorFromPage
+methods and that when a color is picked the color-picked event is emitted and that when
+the eyedropper is dimissed, the color-pick-canceled event is emitted.
+https://bugzilla.mozilla.org/show_bug.cgi?id=1262439
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1262439</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ let win = null;
+ let inspector = null;
+
+ addAsyncTest(async function() {
+ info("Setting up inspector actor");
+
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ inspector = await target.getFront("inspector");
+ win = doc.defaultView;
+ runNextTest();
+ });
+
+ addAsyncTest(async function() {
+ info("Start picking a color from the page");
+ await inspector.pickColorFromPage();
+
+ info("Click in the page and make sure a color-picked event is received");
+ const onColorPicked = waitForEvent("color-picked");
+ win.document.body.click();
+ const color = await onColorPicked;
+
+ is(color, "#000000", "The color-picked event was received with the right color");
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function() {
+ info("Start picking a color from the page");
+ await inspector.pickColorFromPage();
+
+ info("Use the escape key to dismiss the eyedropper");
+ const onPickCanceled = waitForEvent("color-pick-canceled");
+
+ const keyboardEvent = new win.KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ view: win,
+ keyCode: 27
+ });
+ win.document.dispatchEvent(keyboardEvent);
+
+ await onPickCanceled;
+ ok(true, "The color-pick-canceled event was received");
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function() {
+ info("Start picking a color from the page");
+ await inspector.pickColorFromPage();
+
+ info("And cancel the color picking");
+ await inspector.cancelPickColorFromPage();
+
+ runNextTest();
+ });
+
+ function waitForEvent(name) {
+ return new Promise(resolve => inspector.once(name, resolve));
+ }
+
+ runNextTest();
+};
+ </script>
+</head>
+<body>
+<a id="inspectorContent" target="_blank" href="inspector-eyedropper.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none"></div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-pseudoclass-lock.html b/devtools/server/tests/chrome/test_inspector-pseudoclass-lock.html
new file mode 100644
index 0000000000..949066255d
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-pseudoclass-lock.html
@@ -0,0 +1,185 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+const { PSEUDO_CLASSES } = require("devtools/shared/css/constants");
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gInspectee = null;
+let gWalker = null;
+
+async function setup(callback) {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ gInspectee = doc;
+ const inspector = await target.getFront("inspector");
+ ok(inspector.walker, "getWalker() should return an actor.");
+ gWalker = inspector.walker;
+ runNextTest();
+}
+
+function teardown() {
+ gWalker = null;
+ gInspectee = null;
+}
+
+function checkChange(change, expectation) {
+ is(change.type, "pseudoClassLock", "Expect a pseudoclass lock change.");
+ const target = change.target;
+ if (expectation.id) {
+ is(target.id, expectation.id, "Expect a change on node id " + expectation.id);
+ }
+ if (expectation.nodeName) {
+ is(target.nodeName, expectation.nodeName,
+ "Expect a change on node name " + expectation.nodeName);
+ }
+
+ is(target.pseudoClassLocks.length, expectation.pseudos.length,
+ "Expect " + expectation.pseudos.length + " pseudoclass locks.");
+ for (let i = 0; i < expectation.pseudos.length; i++) {
+ const pseudo = expectation.pseudos[i];
+ const enabled = expectation.enabled === undefined ? true : expectation.enabled[i];
+ ok(target.hasPseudoClassLock(pseudo), "Expect lock: " + pseudo);
+ const rawNode = target.rawNode();
+ ok(InspectorUtils.hasPseudoClassLock(rawNode, pseudo),
+ "Expect lock in dom: " + pseudo);
+
+ is(rawNode.matches(pseudo), enabled,
+ `Target should match pseudoclass, '${pseudo}', if enabled (with .matches())`);
+ }
+
+ for (const pseudo of PSEUDO_CLASSES) {
+ if (!expectation.pseudos.some(expected => pseudo === expected)) {
+ ok(!target.hasPseudoClassLock(pseudo), "Don't expect lock: " + pseudo);
+ ok(!InspectorUtils.hasPseudoClassLock(target.rawNode(), pseudo),
+ "Don't expect lock in dom: " + pseudo);
+ }
+ }
+}
+
+function checkMutations(mutations, expectations) {
+ is(mutations.length, expectations.length, "Should get the right number of mutations.");
+ for (let i = 0; i < mutations.length; i++) {
+ checkChange(mutations[i], expectations[i]);
+ }
+}
+
+addTest(function testPseudoClassLock() {
+ let contentNode;
+ let nodeFront;
+ setup(() => {
+ contentNode = gInspectee.querySelector("#b");
+ return promiseDone(gWalker.querySelector(gWalker.rootNode, "#b").then(front => {
+ nodeFront = front;
+ // Lock the pseudoclass alone, no parents.
+ gWalker.addPseudoClassLock(nodeFront, ":active");
+ // Expect a single pseudoClassLock mutation.
+ return promiseOnce(gWalker, "mutations");
+ }).then(mutations => {
+ is(mutations.length, 1, "Should get one mutation");
+ is(mutations[0].target, nodeFront, "Should be the node we tried to apply to");
+ checkChange(mutations[0], {
+ id: "b",
+ nodeName: "DIV",
+ pseudos: [":active"],
+ });
+ }).then(() => {
+ // Now add :hover, this time with parents.
+ gWalker.addPseudoClassLock(nodeFront, ":hover", {parents: true});
+ return promiseOnce(gWalker, "mutations");
+ }).then(mutations => {
+ const expectedMutations = [{
+ id: "b",
+ nodeName: "DIV",
+ pseudos: [":hover", ":active"],
+ },
+ {
+ id: "longlist",
+ nodeName: "DIV",
+ pseudos: [":hover"],
+ },
+ {
+ nodeName: "BODY",
+ pseudos: [":hover"],
+ },
+ {
+ nodeName: "HTML",
+ pseudos: [":hover"],
+ }];
+ checkMutations(mutations, expectedMutations);
+ }).then(() => {
+ // Now remove the :hover on all parents
+ gWalker.removePseudoClassLock(nodeFront, ":hover", {parents: true});
+ return promiseOnce(gWalker, "mutations");
+ }).then(mutations => {
+ const expectedMutations = [{
+ id: "b",
+ nodeName: "DIV",
+ // Should still have :active on the original node.
+ pseudos: [":active"],
+ },
+ {
+ id: "longlist",
+ nodeName: "DIV",
+ pseudos: [],
+ },
+ {
+ nodeName: "BODY",
+ pseudos: [],
+ },
+ {
+ nodeName: "HTML",
+ pseudos: [],
+ }];
+ checkMutations(mutations, expectedMutations);
+ }).then(() => {
+ gWalker.addPseudoClassLock(nodeFront, ":hover", {enabled: false});
+ return promiseOnce(gWalker, "mutations");
+ }).then(mutations => {
+ is(mutations.length, 1, "Should get one mutation");
+ is(mutations[0].target, nodeFront, "Should be the node we tried to apply to");
+ checkChange(mutations[0], {
+ id: "b",
+ nodeName: "DIV",
+ pseudos: [":hover", ":active"],
+ enabled: [false, true],
+ });
+ }).then(() => {
+ // Now shut down the walker and make sure that clears up the remaining lock.
+ return gWalker.release();
+ }).then(() => {
+ ok(!InspectorUtils.hasPseudoClassLock(contentNode, ":active"),
+ "Pseudoclass should have been removed during destruction.");
+ teardown();
+ }).then(runNextTest));
+ });
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-reload.html b/devtools/server/tests/chrome/test_inspector-reload.html
new file mode 100644
index 0000000000..09bd31cf75
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-reload.html
@@ -0,0 +1,90 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gInspectee = null;
+let gWalker = null;
+let gResourceCommand = null;
+let gCommands = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { commands, doc } = await attachURL(url);
+ const target = commands.targetCommand.targetFront;
+ const inspector = await target.getFront("inspector");
+ gInspectee = doc;
+ const walker = inspector.walker;
+ gWalker = await inspector.getWalker();
+ gResourceCommand = commands.resourceCommand;
+ gCommands = commands;
+
+ ok(walker === gWalker, "getWalker() twice should return the same walker.");
+ runNextTest();
+});
+
+addTest(async function testReload() {
+ const oldRootID = gWalker.rootNode.actorID;
+
+ info("Start watching for root nodes and wait for the initial root node");
+ let rootNodeResolve;
+ let rootNodePromise = new Promise(r => (rootNodeResolve = r));
+ const onAvailable = rootNodeFront => rootNodeResolve(rootNodeFront);
+ await gResourceCommand.watchResources([gResourceCommand.TYPES.ROOT_NODE], {
+ onAvailable,
+ });
+ await rootNodePromise;
+
+ info("Retrieve the node front for the selector `#a`");
+ const nodeFront = await gWalker.querySelector(gWalker.rootNode, "#a");
+ ok(nodeFront.actorID, "Node front has a valid actor ID");
+
+ info("Reload the page and wait for the newRoot mutation");
+ rootNodePromise = new Promise(r => (rootNodeResolve = r));
+
+ gInspectee.defaultView.location.reload();
+ await rootNodePromise;
+ gWalker = (await gCommands.targetCommand.targetFront.getFront("inspector")).walker;
+
+ info("Retrieve the (new) node front for the selector `#a`");
+ const newNodeFront = await gWalker.querySelector(gWalker.rootNode, "#a");
+ ok(newNodeFront.actorID, "Got a new actor ID");
+ ok(gWalker.rootNode.actorID != oldRootID, "Root node should have changed.");
+
+ runNextTest();
+});
+
+addTest(function cleanup() {
+ gWalker = null;
+ gInspectee = null;
+ gResourceCommand = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-resize.html b/devtools/server/tests/chrome/test_inspector-resize.html
new file mode 100644
index 0000000000..e0cf9abade
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-resize.html
@@ -0,0 +1,69 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test that the inspector actor emits "resize" events when the page is resized.
+https://bugzilla.mozilla.org/show_bug.cgi?id=1222409
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1222409</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ let win = null;
+ let inspector = null;
+
+ addAsyncTest(async function setup() {
+ info("Setting up inspector and walker actors.");
+
+ const url = document.getElementById("inspectorContent").href;
+
+ const { target, doc } = await attachURL(url);
+ inspector = await target.getFront("inspector");
+ win = doc.defaultView;
+ runNextTest();
+ });
+
+ addAsyncTest(async function() {
+ const walker = inspector.walker;
+
+ // We can't receive events from the walker if we haven't first executed a
+ // method on the actor to initialize it.
+ await walker.querySelector(walker.rootNode, "img");
+
+ const {outerWidth, outerHeight} = win;
+ // eslint-disable-next-line new-cap
+ const onResize = new Promise(resolve => {
+ walker.once("resize", () => {
+ resolve();
+ });
+ });
+ win.resizeTo(800, 600);
+ await onResize;
+
+ ok(true, "The resize event was emitted");
+ win.resizeTo(outerWidth, outerHeight);
+
+ runNextTest();
+ });
+
+ runNextTest();
+};
+ </script>
+</head>
+<body>
+<a id="inspectorContent" target="_blank" href="inspector-search-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-resolve-url.html b/devtools/server/tests/chrome/test_inspector-resolve-url.html
new file mode 100644
index 0000000000..ddf68f56ed
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-resolve-url.html
@@ -0,0 +1,87 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=921102
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 921102</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gInspector;
+let gDoc;
+
+addTest(async function() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ gInspector = await target.getFront("inspector");
+ gDoc = doc;
+ runNextTest();
+});
+
+addTest(function() {
+ info("Resolve a relative URL without providing a context node");
+ gInspector.resolveRelativeURL("test.png?id=4#wow").then(url => {
+ is(url, "chrome://mochitests/content/chrome/devtools/server/tests/" +
+ "chrome/test.png?id=4#wow");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Resolve an absolute URL without providing a context node");
+ gInspector.resolveRelativeURL("chrome://mochitests/content/chrome/" +
+ "devtools/server/").then(url => {
+ is(url, "chrome://mochitests/content/chrome/devtools/server/");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Resolve a relative URL providing a context node");
+ const node = gDoc.querySelector(".big-horizontal");
+ gInspector.resolveRelativeURL("test.png?id=4#wow", node).then(url => {
+ is(url, "chrome://mochitests/content/chrome/devtools/server/tests/" +
+ "chrome/test.png?id=4#wow");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Resolve an absolute URL providing a context node");
+ const node = gDoc.querySelector(".big-horizontal");
+ gInspector.resolveRelativeURL("chrome://mochitests/content/chrome/" +
+ "devtools/server/", node).then(url => {
+ is(url, "chrome://mochitests/content/chrome/devtools/server/");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ gInspector = null;
+ gDoc = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=921102">Mozilla Bug 921102</a>
+<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-scroll-into-view.html b/devtools/server/tests/chrome/test_inspector-scroll-into-view.html
new file mode 100644
index 0000000000..a107f9ba4a
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-scroll-into-view.html
@@ -0,0 +1,60 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=901250
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 901250</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ const walker = inspector.walker;
+
+ const id = "#scroll-into-view";
+ let rect = doc.querySelector(id).getBoundingClientRect();
+ const nodeFront = await walker.querySelector(walker.rootNode, id);
+ let inViewport = rect.x >= 0 &&
+ rect.y >= 0 &&
+ rect.y <= doc.defaultView.innerHeight &&
+ rect.x <= doc.defaultView.innerWidth;
+
+ ok(!inViewport, "Element is not in viewport initially");
+
+ await nodeFront.scrollIntoView();
+
+ await new Promise(res => SimpleTest.executeSoon(res));
+
+ rect = doc.querySelector(id).getBoundingClientRect();
+ inViewport = rect.x >= 0 &&
+ rect.y >= 0 &&
+ rect.y <= doc.defaultView.innerHeight &&
+ rect.x <= doc.defaultView.innerWidth;
+ ok(inViewport, "Element is in viewport after calling nodeFront.scrollIntoView");
+
+ await target.destroy();
+ SimpleTest.finish();
+};
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=901250">Mozilla Bug 901250</a>
+<a id="inspectorContent" target="_blank" href="inspector-traversal-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-search-front.html b/devtools/server/tests/chrome/test_inspector-search-front.html
new file mode 100644
index 0000000000..a78700e8e6
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-search-front.html
@@ -0,0 +1,163 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=835896
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 835896</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ let walkerFront = null;
+ let inspectorCommand = null;
+
+ // WalkerFront and Inspector Command specific tests. These aren't to exercise search
+ // edge cases so much as to test the state the Front maintains between
+ // searches.
+
+ addAsyncTest(async function setup() {
+ info("Setting up inspector and walker actors.");
+
+ const url = document.getElementById("inspectorContent").href;
+
+ const { commands } = await attachURL(url);
+ const target = commands.targetCommand.targetFront;
+ const inspector = await target.getFront("inspector");
+
+ walkerFront = inspector.walker;
+ inspectorCommand = commands.inspectorCommand;
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function testWalkerFrontDefaults() {
+ info("Testing search API using WalkerFront and Inspector Command.");
+ const nodes = await walkerFront.querySelectorAll(walkerFront.rootNode, "h2");
+ const fronts = await nodes.items();
+
+ const commandResult = await inspectorCommand.findNextNode("");
+ ok(!commandResult, "Null result on front when searching for ''");
+
+ let results = await inspectorCommand.findNextNode("h2");
+ isDeeply(results, {
+ node: fronts[0],
+ resultsIndex: 0,
+ resultsLength: 3,
+ }, "Default options work");
+
+ results = await inspectorCommand.findNextNode("h2", { });
+ isDeeply(results, {
+ node: fronts[1],
+ resultsIndex: 1,
+ resultsLength: 3,
+ }, "Search works with empty options");
+
+ // Clear search data to remove result state on the front
+ await inspectorCommand.findNextNode("");
+ runNextTest();
+ });
+
+ addAsyncTest(async function testMultipleSearches() {
+ info("Testing search API using WalkerFront and Inspector Command (reverse=false)");
+ const nodes = await walkerFront.querySelectorAll(walkerFront.rootNode, "h2");
+ const fronts = await nodes.items();
+
+ let results = await inspectorCommand.findNextNode("h2");
+ isDeeply(results, {
+ node: fronts[0],
+ resultsIndex: 0,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=false)");
+
+ results = await inspectorCommand.findNextNode("h2");
+ isDeeply(results, {
+ node: fronts[1],
+ resultsIndex: 1,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=false)");
+
+ results = await inspectorCommand.findNextNode("h2");
+ isDeeply(results, {
+ node: fronts[2],
+ resultsIndex: 2,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=false)");
+
+ results = await inspectorCommand.findNextNode("h2");
+ isDeeply(results, {
+ node: fronts[0],
+ resultsIndex: 0,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=false)");
+
+ // Clear search data to remove result state on the front
+ await inspectorCommand.findNextNode("");
+ runNextTest();
+ });
+
+ addAsyncTest(async function testMultipleSearchesReverse() {
+ info("Testing search API using WalkerFront and Inspector Command (reverse=true)");
+ const nodes = await walkerFront.querySelectorAll(walkerFront.rootNode, "h2");
+ const fronts = await nodes.items();
+
+ let results = await inspectorCommand.findNextNode("h2", {reverse: true});
+ isDeeply(results, {
+ node: fronts[2],
+ resultsIndex: 2,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=true)");
+
+ results = await inspectorCommand.findNextNode("h2", {reverse: true});
+ isDeeply(results, {
+ node: fronts[1],
+ resultsIndex: 1,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=true)");
+
+ results = await inspectorCommand.findNextNode("h2", {reverse: true});
+ isDeeply(results, {
+ node: fronts[0],
+ resultsIndex: 0,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=true)");
+
+ results = await inspectorCommand.findNextNode("h2", {reverse: true});
+ isDeeply(results, {
+ node: fronts[2],
+ resultsIndex: 2,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=true)");
+
+ results = await inspectorCommand.findNextNode("h2", {reverse: false});
+ isDeeply(results, {
+ node: fronts[0],
+ resultsIndex: 0,
+ resultsLength: 3,
+ }, "Search works with multiple results (reverse=false)");
+
+ // Clear search data to remove result state on the command
+ await inspectorCommand.findNextNode("");
+ runNextTest();
+ });
+
+ runNextTest();
+};
+ </script>
+</head>
+<body>
+<a id="inspectorContent" target="_blank" href="inspector-search-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector-template.html b/devtools/server/tests/chrome/test_inspector-template.html
new file mode 100644
index 0000000000..6fbc7742c6
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector-template.html
@@ -0,0 +1,66 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1078374
+Display template tag content in inspector.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ let gWalker = null;
+
+ addAsyncTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+
+ runNextTest();
+ });
+
+ addAsyncTest(async function testWalker() {
+ const nodeFront = await gWalker.querySelector(gWalker.rootNode, "template");
+
+ let children = await gWalker.children(nodeFront);
+ is(children.nodes.length, 1, "Found one child under the template element");
+
+ const docFragment = children.nodes[0];
+ is(docFragment.nodeName, "#document-fragment",
+ "First child under <template> is a document-fragment");
+
+ children = await gWalker.children(docFragment);
+ is(children.nodes.length, 1, "Found one child under the template element");
+
+ const p = children.nodes[0];
+ is(p.nodeName, "P",
+ "First child under the document-fragment is a p element");
+
+ runNextTest();
+ });
+
+ runNextTest();
+};
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-template.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector_getImageData-wait-for-load.html b/devtools/server/tests/chrome/test_inspector_getImageData-wait-for-load.html
new file mode 100644
index 0000000000..129116b913
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector_getImageData-wait-for-load.html
@@ -0,0 +1,133 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Tests for InspectorActor.getImageData() in following cases:
+ * Image takes too long to load (the method rejects after a timeout).
+ * Image is loading when the method is called and the load finishes before
+ timeout.
+ * Image fails to load.
+
+https://bugzilla.mozilla.org/show_bug.cgi?id=1192536
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1192536</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+const PATH = "https://example.com/chrome/devtools/server/tests/chrome/";
+const BASE_IMAGE = PATH + "inspector-delay-image-response.sjs";
+const DELAYED_IMAGE = BASE_IMAGE + "?delay=300";
+const TIMEOUT_IMAGE = BASE_IMAGE + "?delay=50000";
+const NONEXISTENT_IMAGE = PATH + "this-does-not-exist.png";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+function pushPref(preferenceName, value) {
+ return new Promise(resolve => {
+ const options = {"set": [[preferenceName, value]]};
+ SpecialPowers.pushPrefEnv(options, resolve);
+ });
+}
+
+let gImg = null;
+let gNodeFront = null;
+let gWalker = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target, doc } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ gNodeFront = await gWalker.querySelector(gWalker.rootNode, "img.custom");
+ gImg = doc.querySelector("img.custom");
+ ok(gNodeFront, "Got the image NodeFront.");
+ ok(gImg, "Got the image Node.");
+ runNextTest();
+});
+
+addTest(async function testTimeout() {
+ info("Testing that the method aborts if the image takes too long to load.");
+
+ // imageToImageData() only times out when flags.testing is not set.
+ await pushPref("devtools.testing", false);
+
+ gImg.src = TIMEOUT_IMAGE;
+
+ info("Calling getImageData().");
+ ensureRejects(gNodeFront.getImageData(), "Timeout image").then(runNextTest);
+});
+
+addTest(async function testNonExistentImage() {
+ info("Testing that non-existent image causes a rejection.");
+
+ // This test shouldn't hit the timeout.
+ await pushPref("devtools.testing", true);
+
+ gImg.src = NONEXISTENT_IMAGE;
+
+ info("Calling getImageData().");
+ ensureRejects(gNodeFront.getImageData(), "Non-existent image").then(runNextTest);
+});
+
+addTest(async function testDelayedImage() {
+ info("Testing that the method waits for an image to load.");
+
+ // This test shouldn't hit the timeout.
+ await pushPref("devtools.testing", true);
+
+ gImg.src = DELAYED_IMAGE;
+
+ info("Calling getImageData().");
+ checkImageData(gNodeFront.getImageData()).then(runNextTest);
+});
+
+addTest(function cleanup() {
+ gImg = null;
+ gNodeFront = null;
+ gWalker = null;
+ runNextTest();
+});
+
+/**
+ * Asserts that the given promise rejects.
+ */
+function ensureRejects(promise, desc) {
+ return promise.then(() => {
+ ok(false, desc + ": promise resolved unexpectedly.");
+ }, () => {
+ ok(true, desc + ": promise rejected as expected.");
+ });
+}
+
+/**
+ * Waits for the call to getImageData() the resolve and checks that the image
+ * size is reported correctly.
+ */
+function checkImageData(promise, { width, height } = { width: 1, height: 1 }) {
+ return promise.then(({ size }) => {
+ is(size.naturalWidth, width, "The width is correct.");
+ is(size.naturalHeight, height, "The height is correct.");
+ });
+}
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1192536">Mozilla Bug 1192536</a>
+<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector_getImageData.html b/devtools/server/tests/chrome/test_inspector_getImageData.html
new file mode 100644
index 0000000000..d95b0e5fd3
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector_getImageData.html
@@ -0,0 +1,142 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=932937
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 932937</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(async function testLargeImage() {
+ // Select the image node from the test page
+ const img = await gWalker.querySelector(gWalker.rootNode, ".big-horizontal");
+ ok(img, "Image node found in the test page");
+ ok(img.getImageData, "Image node has the getImageData function");
+ const imageData = await img.getImageData(100);
+ ok(imageData.data, "Image data actor was sent back");
+ ok(imageData.size, "Image size info was sent back too");
+ is(imageData.size.naturalWidth, 5333, "Natural width of the image correct");
+ is(imageData.size.naturalHeight, 3000, "Natural width of the image correct");
+ ok(imageData.size.resized, "Image was resized");
+ const str = await imageData.data.string();
+ ok(str, "We have an image data string!");
+ testResizing(imageData, str);
+});
+
+addTest(async function testLargeCanvas() {
+ // Select the canvas node from the test page
+ const canvas = await gWalker.querySelector(gWalker.rootNode, ".big-vertical");
+ ok(canvas, "Image node found in the test page");
+ ok(canvas.getImageData, "Image node has the getImageData function");
+ const imageData = await canvas.getImageData(350);
+ ok(imageData.data, "Image data actor was sent back");
+ ok(imageData.size, "Image size info was sent back too");
+ is(imageData.size.naturalWidth, 1000, "Natural width of the image correct");
+ is(imageData.size.naturalHeight, 2000, "Natural width of the image correct");
+ ok(imageData.size.resized, "Image was resized");
+ const str = await imageData.data.string();
+ ok(str, "We have an image data string!");
+ testResizing(imageData, str);
+});
+
+addTest(async function testSmallImage() {
+ // Select the small image node from the test page
+ const img = await gWalker.querySelector(gWalker.rootNode, ".small");
+ ok(img, "Image node found in the test page");
+ ok(img.getImageData, "Image node has the getImageData function");
+ const imageData = await img.getImageData();
+ ok(imageData.data, "Image data actor was sent back");
+ ok(imageData.size, "Image size info was sent back too");
+ is(imageData.size.naturalWidth, 245, "Natural width of the image correct");
+ is(imageData.size.naturalHeight, 240, "Natural width of the image correct");
+ ok(!imageData.size.resized, "Image was NOT resized");
+ const str = await imageData.data.string();
+ ok(str, "We have an image data string!");
+ testResizing(imageData, str);
+});
+
+addTest(async function testDataImage() {
+ // Select the data image node from the test page
+ const img = await gWalker.querySelector(gWalker.rootNode, ".data");
+ ok(img, "Image node found in the test page");
+ ok(img.getImageData, "Image node has the getImageData function");
+ const imageData = await img.getImageData(14);
+ ok(imageData.data, "Image data actor was sent back");
+ ok(imageData.size, "Image size info was sent back too");
+ is(imageData.size.naturalWidth, 28, "Natural width of the image correct");
+ is(imageData.size.naturalHeight, 28, "Natural width of the image correct");
+ ok(imageData.size.resized, "Image was resized");
+ const str = await imageData.data.string();
+ ok(str, "We have an image data string!");
+ testResizing(imageData, str);
+});
+
+addTest(async function testNonImgOrCanvasElements() {
+ const body = await gWalker.querySelector(gWalker.rootNode, "body");
+ await ensureRejects(body.getImageData(), "Invalid element");
+ runNextTest();
+});
+
+addTest(function cleanup() {
+ gWalker = null;
+ runNextTest();
+});
+
+/**
+ * Checks if the server told the truth about resizing the image
+ */
+function testResizing(imageData, str) {
+ const img = document.createElement("img");
+ img.addEventListener("load", () => {
+ const resized = !(img.naturalWidth == imageData.size.naturalWidth &&
+ img.naturalHeight == imageData.size.naturalHeight);
+ is(imageData.size.resized, resized, "Server told the truth about resizing");
+ runNextTest();
+ });
+ img.src = str;
+}
+
+/**
+ * Asserts that the given promise rejects.
+ */
+function ensureRejects(promise, desc) {
+ return promise.then(() => {
+ ok(false, desc + ": promise resolved unexpectedly.");
+ }, () => {
+ ok(true, desc + ": promise rejected as expected.");
+ });
+}
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=932937">Mozilla Bug 932937</a>
+<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector_getImageDataFromURL.html b/devtools/server/tests/chrome/test_inspector_getImageDataFromURL.html
new file mode 100644
index 0000000000..451c49dcc3
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector_getImageDataFromURL.html
@@ -0,0 +1,116 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Tests for InspectorActor.getImageDataFromURL() in following cases:
+ * Normal case, image loads after a small delay.
+ * Image takes too long to load (the method rejects after a timeout).
+ * Image fails to load.
+
+https://bugzilla.mozilla.org/show_bug.cgi?id=1192536
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1192536</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+const PATH = "https://example.com/chrome/devtools/server/tests/chrome/";
+const BASE_IMAGE = PATH + "inspector-delay-image-response.sjs";
+const DELAYED_IMAGE = BASE_IMAGE + "?delay=300";
+const TIMEOUT_IMAGE = BASE_IMAGE + "?delay=50000";
+const NONEXISTENT_IMAGE = PATH + "this-does-not-exist.png";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+function pushPref(preferenceName, value) {
+ return new Promise(resolve => {
+ const options = {"set": [[preferenceName, value]]};
+ SpecialPowers.pushPrefEnv(options, resolve);
+ });
+}
+
+let gInspector = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ gInspector = await target.getFront("inspector");
+ runNextTest();
+});
+
+addTest(async function testTimeout() {
+ info("Testing that the method aborts if the image takes too long to load.");
+
+ // imageToImageData() only times out when flags.testing is not set.
+ await pushPref("devtools.testing", false);
+
+ ensureRejects(gInspector.getImageDataFromURL(TIMEOUT_IMAGE),
+ "Image that loads for too long").then(runNextTest);
+});
+
+addTest(async function testNonExistentImage() {
+ info("Testing that non-existent image causes a rejection.");
+
+ // This test shouldn't hit the timeout.
+ await pushPref("devtools.testing", true);
+
+ ensureRejects(gInspector.getImageDataFromURL(NONEXISTENT_IMAGE),
+ "Non-existent image").then(runNextTest);
+});
+
+addTest(async function testNormalImage() {
+ info("Testing that the method waits for an image to load.");
+
+ // This test shouldn't hit the timeout.
+ await pushPref("devtools.testing", true);
+
+ checkImageData(gInspector.getImageDataFromURL(DELAYED_IMAGE)).then(runNextTest);
+});
+
+addTest(function cleanup() {
+ gInspector = null;
+ runNextTest();
+});
+
+/**
+ * Asserts that the given promise rejects.
+ */
+function ensureRejects(promise, desc) {
+ return promise.then(() => {
+ ok(false, desc + ": promise resolved unexpectedly.");
+ }, () => {
+ ok(true, desc + ": promise rejected as expected.");
+ });
+}
+
+/**
+ * Waits for the call to getImageData() the resolve and checks that the image
+ * size is reported correctly.
+ */
+function checkImageData(promise, { width, height } = { width: 1, height: 1 }) {
+ return promise.then(({ size }) => {
+ is(size.naturalWidth, width, "The width is correct.");
+ is(size.naturalHeight, height, "The height is correct.");
+ });
+}
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1192536">Mozilla Bug 1192536</a>
+<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector_getNodeFromActor.html b/devtools/server/tests/chrome/test_inspector_getNodeFromActor.html
new file mode 100644
index 0000000000..c3c5d32af9
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector_getNodeFromActor.html
@@ -0,0 +1,84 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1155653
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1155653</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker;
+
+addTest(async function() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ runNextTest();
+});
+
+addTest(function() {
+ info("Try to get a NodeFront from an invalid actorID");
+ gWalker.getNodeFromActor("invalid", ["node"]).then(node => {
+ ok(!node, "The node returned is null");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get a NodeFront from a valid actorID but invalid path");
+ gWalker.getNodeFromActor(gWalker.actorID, ["invalid", "path"]).then(node => {
+ ok(!node, "The node returned is null");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get a NodeFront from a valid actorID and valid path");
+ gWalker.getNodeFromActor(gWalker.actorID, ["rootDoc"]).then(rootDocNode => {
+ ok(rootDocNode, "A node was returned");
+ is(rootDocNode, gWalker.rootNode, "The right node was returned");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get a NodeFront from a valid actorID and valid complex path");
+ gWalker.getNodeFromActor(gWalker.actorID,
+ ["targetActor", "window", "document", "body"]).then(bodyNode => {
+ ok(bodyNode, "A node was returned");
+ gWalker.querySelector(gWalker.rootNode, "body").then(node => {
+ is(bodyNode, node, "The body node was returned");
+ runNextTest();
+ });
+ });
+});
+
+addTest(function() {
+ gWalker = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1155653">Mozilla Bug 1155653</a>
+<a id="inspectorContent" target="_blank" href="inspector_getImageData.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_inspector_getOffsetParent.html b/devtools/server/tests/chrome/test_inspector_getOffsetParent.html
new file mode 100644
index 0000000000..09da7d55d1
--- /dev/null
+++ b/devtools/server/tests/chrome/test_inspector_getOffsetParent.html
@@ -0,0 +1,129 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1345119
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1345119</title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+var gWalker;
+var gHTMLNode;
+var gBodyNode;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ gBodyNode = await gWalker.querySelector(gWalker.rootNode, "body");
+ gHTMLNode = await gWalker.querySelector(gWalker.rootNode, "html");
+ runNextTest();
+});
+
+addTest(function() {
+ info("Try to get the offset parent for a dead node (null)");
+ gWalker.getOffsetParent(null).then(offsetParent => {
+ ok(!offsetParent, "No offset parent found");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get the offset parent for a node that is absolutely positioned inside a " +
+ "relative node");
+ gWalker.querySelector(gWalker.rootNode, "#absolute_child").then(node => {
+ return gWalker.getOffsetParent(node);
+ }).then(offsetParent => {
+ ok(offsetParent, "The node has an offset parent");
+ gWalker.querySelector(gWalker.rootNode, "#relative_parent").then(parent => {
+ ok(offsetParent === parent, "The offset parent is the correct node");
+ runNextTest();
+ });
+ });
+});
+
+addTest(function() {
+ info("Try to get the offset parent for a node that is absolutely positioned outside a" +
+ " relative node");
+ gWalker.querySelector(gWalker.rootNode, "#no_parent").then(node => {
+ return gWalker.getOffsetParent(node);
+ }).then(offsetParent => {
+ ok(offsetParent === gBodyNode || offsetParent === gHTMLNode,
+ "The node's offset parent is the body or html node");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get the offset parent for a relatively positioned node");
+ gWalker.querySelector(gWalker.rootNode, "#relative_parent").then(node => {
+ return gWalker.getOffsetParent(node);
+ }).then(offsetParent => {
+ ok(offsetParent === gBodyNode || offsetParent === gHTMLNode,
+ "The node's offset parent is the body or html node");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get the offset parent for a statically positioned node");
+ gWalker.querySelector(gWalker.rootNode, "#static").then(node => {
+ return gWalker.getOffsetParent(node);
+ }).then(offsetParent => {
+ ok(offsetParent === gBodyNode || offsetParent === gHTMLNode,
+ "The node's offset parent is the body or html node");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get the offset parent for a fixed positioned node");
+ gWalker.querySelector(gWalker.rootNode, "#fixed").then(node => {
+ return gWalker.getOffsetParent(node);
+ }).then(offsetParent => {
+ ok(offsetParent === gBodyNode || offsetParent === gHTMLNode,
+ "The node's offset parent is the body or html node");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ info("Try to get the offset parent for the body");
+ gWalker.querySelector(gWalker.rootNode, "body").then(node => {
+ return gWalker.getOffsetParent(node);
+ }).then(offsetParent => {
+ ok(!offsetParent, "The body has no offset parent");
+ runNextTest();
+ });
+});
+
+addTest(function() {
+ gWalker = null;
+ gBodyNode = null;
+ runNextTest();
+});
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1345119">Mozilla Bug 1345119</a>
+<a id="inspectorContent" target="_blank" href="inspector_getOffsetParent.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_makeGlobalObjectReference.html b/devtools/server/tests/chrome/test_makeGlobalObjectReference.html
new file mode 100644
index 0000000000..d800798427
--- /dev/null
+++ b/devtools/server/tests/chrome/test_makeGlobalObjectReference.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=914405
+
+Debugger.prototype.makeGlobalObjectReference should dereference WindowProxy
+(outer window) objects.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Mozilla Bug 914405</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+const {addSandboxedDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs");
+addSandboxedDebuggerToGlobal(globalThis);
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ // Load one of our iframes over http to force it in a different compartment
+ // from the current window and the other iframe.
+ const iframe = document.createElement("iframe");
+ const baseURL = "http://mochi.test:8888/chrome/devtools/server/tests/chrome/";
+ iframe.src = baseURL + "iframe1_makeGlobalObjectReference.html";
+ iframe.onload = iframeOnLoad;
+ document.body.appendChild(iframe);
+
+ function iframeOnLoad() {
+ const dbg = new Debugger();
+
+ // 'o' for 'outer window'
+ const g1o = iframe.contentWindow;
+ ok(!dbg.hasDebuggee(g1o), "iframe is not initially a debuggee");
+
+ // Like addDebuggee, makeGlobalObjectReference innerizes.
+ // 'i' stands for 'inner window'.
+ // 'DO' stands for 'Debugger.Object'.
+ const g1iDO = dbg.makeGlobalObjectReference(g1o);
+ ok(!dbg.hasDebuggee(g1o),
+ "makeGlobalObjectReference does not add g1 as debuggee, designated via outer");
+ ok(!dbg.hasDebuggee(g1iDO),
+ "makeGlobalObjectReference does not add g1 as debuggee, designated via D.O ");
+
+ // Wrapping an object automatically outerizes it, so dereferencing an
+ // inner object D.O gets you an outer object.
+ // ('===' does distinguish inner and outer objects.)
+ // (That's a capital '=', if you must know.)
+ ok(g1iDO.unsafeDereference() === g1o, "g1iDO has the right referent");
+
+ // However, Debugger.Objects do distinguish inner and outer windows.
+ const g1oDO = g1iDO.makeDebuggeeValue(g1o);
+ ok(g1iDO !== g1oDO, "makeDebuggeeValue doesn't innerize");
+ ok(g1iDO.unsafeDereference() === g1oDO.unsafeDereference(),
+ "unsafeDereference() outerizes," +
+ " so inner and outer window D.Os both dereference to outer");
+
+ ok(dbg.addDebuggee(g1o) === g1iDO, "addDebuggee returns the inner window's D.O");
+ ok(dbg.hasDebuggee(g1o), "addDebuggee adds the correct global");
+ ok(dbg.hasDebuggee(g1iDO),
+ "hasDebuggee can take a D.O referring to the inner window");
+ ok(dbg.hasDebuggee(g1oDO),
+ "hasDebuggee can take a D.O referring to the outer window");
+
+ const iframe2 = document.createElement("iframe");
+ iframe2.src = "iframe2_makeGlobalObjectReference.html";
+ iframe2.onload = iframe2OnLoad;
+ document.body.appendChild(iframe2);
+
+ function iframe2OnLoad() {
+ // makeGlobalObjectReference dereferences CCWs.
+ const g2o = iframe2.contentWindow;
+ g2o.g1o = g1o;
+
+ const g2iDO = dbg.addDebuggee(g2o);
+ const g2g1oDO = g2iDO.getOwnPropertyDescriptor("g1o").value;
+ ok(g2g1oDO !== g1oDO, "g2's cross-compartment wrapper for g1o gets its own D.O");
+ ok(g2g1oDO.unwrap() === g1oDO,
+ "unwrapping g2's cross-compartment wrapper for g1o gets the right D.O");
+ ok(dbg.makeGlobalObjectReference(g2g1oDO) === g1iDO,
+ "makeGlobalObjectReference unwraps cross-compartment wrappers, and innerizes");
+
+ SimpleTest.finish();
+ }
+ }
+};
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory.html b/devtools/server/tests/chrome/test_memory.html
new file mode 100644
index 0000000000..79ba29c913
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory.html
@@ -0,0 +1,39 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 923275 - Add a memory monitor widget to the developer toolbar
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ const measurement = await memory.measure();
+ ok(measurement.total > 0, "total memory is valid");
+ ok(measurement.domSize > 0, "domSize is valid");
+ ok(measurement.styleSize > 0, "styleSize is valid");
+ ok(measurement.jsObjectsSize > 0, "jsObjectsSize is valid");
+ ok(measurement.jsStringsSize > 0, "jsStringsSize is valid");
+ ok(measurement.jsOtherSize > 0, "jsOtherSize is valid");
+ ok(measurement.otherSize > 0, "otherSize is valid");
+ ok(measurement.jsMilliseconds, "jsMilliseconds is valid");
+ ok(measurement.nonJSMilliseconds, "nonJSMilliseconds is valid");
+ await destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_allocations_02.html b/devtools/server/tests/chrome/test_memory_allocations_02.html
new file mode 100644
index 0000000000..632903bc04
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_allocations_02.html
@@ -0,0 +1,80 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1132764 - Test controlling the maximum allocations log length over the RDP.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ const allocs = [];
+ let eventsFired = 0;
+ let intervalId = null;
+ function onAlloc() {
+ eventsFired++;
+ }
+ function startAllocating() {
+ intervalId = setInterval(() => {
+ for (let i = 100000; --i;) {
+ allocs.push({});
+ }
+ }, 1);
+ }
+ function stopAllocating() {
+ clearInterval(intervalId);
+ }
+
+ memory.on("allocations", onAlloc);
+
+ await memory.startRecordingAllocations({
+ drainAllocationsTimeout: 10,
+ });
+
+ await waitUntil(() => eventsFired > 5);
+ ok(eventsFired > 5,
+ "Some allocation events fired without allocating much via auto drain");
+ await memory.stopRecordingAllocations();
+
+ // Set a really high auto drain timer so we can test if
+ // it fires on GC
+ eventsFired = 0;
+ const startTime = performance.now();
+ const drainTimer = 1000000;
+ await memory.startRecordingAllocations({
+ drainAllocationsTimeout: drainTimer,
+ });
+
+ startAllocating();
+ await waitUntil(() => {
+ Cu.forceGC();
+ return eventsFired > 1;
+ });
+ stopAllocating();
+ ok(performance.now() - drainTimer < startTime,
+ "Allocation events fired on GC before timer");
+ await memory.stopRecordingAllocations();
+
+ memory.off("allocations", onAlloc);
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_allocations_03.html b/devtools/server/tests/chrome/test_memory_allocations_03.html
new file mode 100644
index 0000000000..ca6a1ec1b4
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_allocations_03.html
@@ -0,0 +1,80 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1067491 - Test that frames keep the same index while we are recording.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ await memory.startRecordingAllocations();
+
+ // Allocate twice with the exact same stack (hence setTimeout rather than
+ // allocating directly in the generator), but with getAllocations() calls in
+ // between.
+
+ const allocs = [];
+ function allocator() {
+ allocs.push({});
+ }
+
+ setTimeout(allocator, 1);
+ await waitForTime(2);
+ const first = await memory.getAllocations();
+
+ setTimeout(allocator, 1);
+ await waitForTime(2);
+ const second = await memory.getAllocations();
+
+ await memory.stopRecordingAllocations();
+
+ // Assert that each frame in the first response has the same index in the
+ // second response. This isn't commutative, so we don't check that all
+ // of the second response's frames are the same in the first response,
+ // because there might be new allocations that happen after the first query
+ // but before the second.
+
+ function assertSameFrame(a, b) {
+ info(" First frame = " + JSON.stringify(a, null, 4));
+ info(" Second frame = " + JSON.stringify(b, null, 4));
+
+ is(!!a, !!b);
+ if (!a || !b) {
+ return;
+ }
+
+ is(a.source, b.source);
+ is(a.line, b.line);
+ is(a.column, b.column);
+ is(a.functionDisplayName, b.functionDisplayName);
+ is(a.parent, b.parent);
+ }
+
+ for (let i = 0; i < first.frames.length; i++) {
+ info("Checking frames at index " + i + ":");
+ assertSameFrame(first.frames[i], second.frames[i]);
+ }
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_allocations_04.html b/devtools/server/tests/chrome/test_memory_allocations_04.html
new file mode 100644
index 0000000000..8bb64c591c
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_allocations_04.html
@@ -0,0 +1,62 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1068171 - Test controlling the memory actor's allocation sampling probability.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ const allocs = [];
+ function allocator() {
+ for (let i = 0; i < 100; i++) {
+ allocs.push({});
+ }
+ }
+
+ const testProbability = async function(p, expected) {
+ info("probability = " + p);
+ await memory.startRecordingAllocations({
+ probability: p,
+ });
+ allocator();
+ const response = await memory.getAllocations();
+ await memory.stopRecordingAllocations();
+ return response.allocations.length;
+ };
+
+ is((await testProbability(0.0)), 0,
+ "With probability = 0.0, we shouldn't get any allocations.");
+
+ ok((await testProbability(1.0)) >= 100,
+ "With probability = 1.0, we should get all 100 allocations (plus "
+ + "whatever allocations the actor and SpiderMonkey make).");
+
+ // We don't test any other probabilities because the test would be
+ // non-deterministic. We don't have a way to control the PRNG like we do in
+ // jit-tests
+ // (js/src/jit-test/tests/debug/Memory-allocationsSamplingProbability-*.js).
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_allocations_05.html b/devtools/server/tests/chrome/test_memory_allocations_05.html
new file mode 100644
index 0000000000..590b3358e4
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_allocations_05.html
@@ -0,0 +1,93 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1068144 - Test getting the timestamps for allocations.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ const allocs = [];
+ function allocator() {
+ allocs.push({});
+ }
+
+ // Using setTimeout results in wildly varying delays that make it hard to
+ // test our timestamps and results in intermittent failures. Instead, we
+ // actually spin an empty loop for a whole millisecond.
+ function actuallyWaitOneWholeMillisecond() {
+ const start = window.performance.now();
+ // eslint-disable-next-line curly
+ while (window.performance.now() - start < 1.000);
+ }
+
+ await memory.startRecordingAllocations();
+
+ allocator();
+ actuallyWaitOneWholeMillisecond();
+ allocator();
+ actuallyWaitOneWholeMillisecond();
+ allocator();
+
+ const response = await memory.getAllocations();
+ await memory.stopRecordingAllocations();
+
+ ok(response.allocationsTimestamps, "The response should have timestamps.");
+ is(response.allocationsTimestamps.length, response.allocations.length,
+ "There should be a timestamp for every allocation.");
+
+ const allocatorIndices = response.allocations
+ .map(function(a, idx) {
+ const frame = response.frames[a];
+ if (frame && frame.functionDisplayName === "allocator") {
+ return idx;
+ }
+ return null;
+ })
+ .filter(function(idx) {
+ return idx !== null;
+ });
+
+ is(allocatorIndices.length, 3,
+ "Should have our 3 allocations from the `allocator` timeouts.");
+
+ let lastTimestamp;
+ for (let i = 0; i < 3; i++) {
+ const timestamp = response.allocationsTimestamps[allocatorIndices[i]];
+ info("timestamp", timestamp);
+ ok(timestamp, "We should have a timestamp for the `allocator` allocation.");
+
+ if (lastTimestamp) {
+ const delta = timestamp - lastTimestamp;
+ info("delta since last timestamp", delta);
+ // ms
+ ok(delta >= 1,
+ "The timestamp should be about 1 ms after the last timestamp.");
+ }
+
+ lastTimestamp = timestamp;
+ }
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_allocations_06.html b/devtools/server/tests/chrome/test_memory_allocations_06.html
new file mode 100644
index 0000000000..d6223dd062
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_allocations_06.html
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1132764 - Test controlling the maximum allocations log length over the RDP.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ const allocs = [];
+ function allocator() {
+ allocs.push({});
+ }
+
+ await memory.startRecordingAllocations({
+ maxLogLength: 1,
+ });
+
+ allocator();
+ allocator();
+ allocator();
+
+ const response = await memory.getAllocations();
+ await memory.stopRecordingAllocations();
+
+ is(response.allocations.length, 1,
+ "There should only be one entry in the allocations log.");
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_allocations_07.html b/devtools/server/tests/chrome/test_memory_allocations_07.html
new file mode 100644
index 0000000000..ce5ba4d2ad
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_allocations_07.html
@@ -0,0 +1,58 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1192335 - Test getting the byte sizes for allocations.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ const allocs = [];
+ function allocator() {
+ allocs.push({});
+ }
+
+ await memory.startRecordingAllocations();
+
+ allocator();
+ allocator();
+ allocator();
+
+ const response = await memory.getAllocations();
+ await memory.stopRecordingAllocations();
+
+ ok(response.allocationSizes, "The response should have bytesizes.");
+ is(response.allocationSizes.length, response.allocations.length,
+ "There should be a bytesize for every allocation.");
+ ok(response.allocationSizes.length >= 3,
+ "There are atleast 3 allocations.");
+ ok(response.allocationSizes.every(isPositiveNumber),
+ "every bytesize is a positive number");
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+
+function isPositiveNumber(n) {
+ return typeof n === "number" && n > 0;
+}
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_attach_01.html b/devtools/server/tests/chrome/test_memory_attach_01.html
new file mode 100644
index 0000000000..89f1818292
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_attach_01.html
@@ -0,0 +1,33 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 960671 - Test attaching and detaching from a memory actor.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+ ok(true, "Shouldn't have gotten an error attaching.");
+ await memory.detach();
+ ok(true, "Shouldn't have gotten an error detaching.");
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_attach_02.html b/devtools/server/tests/chrome/test_memory_attach_02.html
new file mode 100644
index 0000000000..89e23f5ed6
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_attach_02.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 960671 - Test attaching and detaching while in the wrong state.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+
+ let e = null;
+ try {
+ await memory.detach();
+ } catch (ee) {
+ e = ee;
+ }
+ ok(e, "Should have hit the wrongState error");
+
+ await memory.attach();
+
+ await memory.attach();
+ ok(true, "We can call attach many times, the duplicates will be ignored");
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_census.html b/devtools/server/tests/chrome/test_memory_census.html
new file mode 100644
index 0000000000..3c351740d3
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_census.html
@@ -0,0 +1,35 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1067491 - Test taking a census over the RDP.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ const census = await memory.takeCensus();
+ is(typeof census, "object");
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_gc_01.html b/devtools/server/tests/chrome/test_memory_gc_01.html
new file mode 100644
index 0000000000..8b2f049602
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_gc_01.html
@@ -0,0 +1,50 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1067491 - Test forcing a gc.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+
+ let beforeGC, afterGC;
+
+ do {
+ let objects = [];
+ for (let i = 0; i < 1000; i++) {
+ const o = {};
+ o[Math.random()] = 1;
+ objects.push(o);
+ }
+ objects = null;
+
+ beforeGC = (await memory.measure()).total;
+
+ await memory.forceGarbageCollection();
+
+ afterGC = (await memory.measure()).total;
+ } while (beforeGC < afterGC);
+
+ ok(true, "The amount of memory after GC should eventually decrease");
+
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_memory_gc_events.html b/devtools/server/tests/chrome/test_memory_gc_events.html
new file mode 100644
index 0000000000..5db1607c91
--- /dev/null
+++ b/devtools/server/tests/chrome/test_memory_gc_events.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1137527 - Test receiving GC events from the memory actor.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Memory monitoring actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="memory-helpers.js" type="application/javascript"></script>
+<script>
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const EventEmitter = require("devtools/shared/event-emitter");
+
+ (async function() {
+ const { memory, target } = await startServerAndGetSelectedTabMemory();
+ await memory.attach();
+
+ const gotGcEvent = new Promise(resolve => {
+ EventEmitter.on(memory, "garbage-collection", gcData => {
+ ok(gcData, "Got GC data");
+ resolve();
+ });
+ });
+
+ memory.forceGarbageCollection();
+ await gotGcEvent;
+
+ await memory.detach();
+ destroyServerAndFinish(target);
+ })();
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_overflowing-body.html b/devtools/server/tests/chrome/test_overflowing-body.html
new file mode 100644
index 0000000000..1fe52e0011
--- /dev/null
+++ b/devtools/server/tests/chrome/test_overflowing-body.html
@@ -0,0 +1,42 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test InspectorUtils.GetOverflowingChildrenOfElement applied to the body element
+-->
+<head>
+<meta charset="utf-8">
+<title>Test InspectorUtils.GetOverflowingChildrenOfElement on the body element</title>
+<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+<style>
+body {
+ overflow: auto;
+ margin: 0;
+}
+.tallBox {
+ overflow: auto;
+ background: lavender;
+ width: 200px;
+ height: 110vh;
+}
+</style>
+<script>
+'use strict';
+
+SimpleTest.waitForExplicitFinish();
+const InspectorUtils = SpecialPowers.InspectorUtils;
+
+function runTests() {
+ const body = document.documentElement;
+ const overflowing_children = InspectorUtils.getOverflowingChildrenOfElement(body);
+
+ is(overflowing_children.length, 1, `body has the expected number of children.`);
+
+ SimpleTest.finish();
+};
+window.onload = runTests;
+</script>
+</head>
+<body>
+<div class="tallBox"><div>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_overflowing-children.html b/devtools/server/tests/chrome/test_overflowing-children.html
new file mode 100644
index 0000000000..8ba81bec3d
--- /dev/null
+++ b/devtools/server/tests/chrome/test_overflowing-children.html
@@ -0,0 +1,131 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test InspectorUtils.GetOverflowingChildrenOfElement in various cases
+-->
+<head>
+<meta charset="utf-8">
+<title>Test InspectorUtils.GetOverflowingChildrenOfElement</title>
+<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+<style>
+/* "e" is our custom tag name for "element" */
+e {
+ background: lightgray;
+ display: inline-block;
+ margin: 10px;
+ padding: 0;
+ border: 0;
+ width: 100px;
+ height: 100px;
+ overflow: auto;
+}
+
+/* "c" is our custom tag name for "child" */
+c {
+ display: block;
+ background: green;
+}
+
+.fixedSize {
+ width: 10px;
+ height: 10px;
+}
+
+.target {
+ background: red;
+}
+</style>
+
+<script>
+'use strict';
+
+SimpleTest.waitForExplicitFinish();
+const InspectorUtils = SpecialPowers.InspectorUtils;
+
+const CASES = [
+ {id: "no_children", expected: 0},
+ {id: "one_child_no_overflow", expected: 0},
+ {id: "margin_left_overflow", expected: 1},
+ {id: "transform_overflow", expected: 1},
+ {id: "nested_overflow", expected: 1},
+ {id: "intermediate_overflow", expected: 1},
+ {id: "multiple_overflow_at_different_depths", expected: 2},
+];
+
+function runTests() {
+ // Assign each child element to an inner id so each of them can be identified for testing.
+ Array.from(document.getElementsByTagName('c')).forEach((e, i) => {e.id = `inner${i}`});
+
+ for (const {id, expected} of CASES) {
+ info(`Checking element id ${id}.`);
+
+ const element = document.getElementById(id);
+ if (!element) {
+ ok(false, `Expected to find element with id ${id}.`);
+ continue;
+ }
+ const overflowing_children = InspectorUtils.getOverflowingChildrenOfElement(element);
+
+ is(overflowing_children.length, expected, `${id} has the expected number of children.`);
+
+ // Check that each child has the "target" class. Otherwise, we're getting the
+ // wrong children. We don't check each child with a test function, because we
+ // don't want to needlessly inflate the number of test functions in the log.
+ // But if we find a child that *doesn't* have the class "target", we report
+ // that as a test failure.
+ for (const child of overflowing_children) {
+ // child is a Node, but not necessarily an Element. We want to get the containing
+ // Element so that we can use its classList, tagName, and id properties.
+ let e = child;
+ if (child.nodeType !== Node.ELEMENT_NODE) {
+ e = child.parentElement;
+ }
+ if (!e.classList.contains("target")) {
+ ok(false, `${id} is reporting this unexpected child as a target: ${e.tagName} id=${e.id}`);
+ }
+ }
+ }
+
+ SimpleTest.finish();
+};
+window.onload = runTests;
+</script>
+</head>
+<body onload="runTests()">
+
+<e id="no_children"></e>
+
+<e id="one_child_no_overflow">
+ <c></c>
+</e>
+
+<e id="margin_left_overflow">
+ <c class="target" style="margin-left:100px">abcd</c>
+</e>
+
+<e id="transform_overflow">
+ <c class="target" style="transform: translate(50px)">abcd</c>
+</e>
+
+<e id="nested_overflow">
+ <c>
+ <c class="target" style="margin-left:100px">abcd</c>
+ </c>
+</e>
+
+<e id="intermediate_overflow">
+ <c class="fixedSize target" style="margin-left:100px">
+ <c></c>
+ </c>
+</e>
+
+<e id="multiple_overflow_at_different_depths">
+ <c class="fixedSize target" style="margin-left:100px">
+ <c></c>
+ </c>
+ <c style="margin-left:100px">
+ <c class="target">abcd</c>
+ </c>
+</e>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_preference.html b/devtools/server/tests/chrome/test_preference.html
new file mode 100644
index 0000000000..b4d23a24aa
--- /dev/null
+++ b/devtools/server/tests/chrome/test_preference.html
@@ -0,0 +1,128 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 943251 - Test preferences actor
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test Preference Actor</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+function runTests() {
+ const {require} = ChromeUtils.importESModule("resource://devtools/shared/loader/Loader.sys.mjs");
+ const {DevToolsClient} = require("devtools/client/devtools-client");
+ const {DevToolsServer} = require("devtools/server/devtools-server");
+
+ SimpleTest.waitForExplicitFinish();
+
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ client.connect().then(function onConnect() {
+ return client.mainRoot.getFront("preference");
+ }).then(function(p) {
+ const prefs = {};
+
+ const localPref = {
+ boolPref: true,
+ intPref: 0x1234,
+ charPref: "Hello World",
+ };
+
+ function checkValues() {
+ is(prefs.boolPref, localPref.boolPref, "read/write bool pref");
+ is(prefs.intPref, localPref.intPref, "read/write int pref");
+ is(prefs.charPref, localPref.charPref, "read/write string pref");
+
+ ["test.all.bool", "test.all.int", "test.all.string"].forEach(function(key) {
+ let expectedValue;
+ switch (Services.prefs.getPrefType(key)) {
+ case Ci.nsIPrefBranch.PREF_STRING:
+ expectedValue = Services.prefs.getCharPref(key);
+ break;
+ case Ci.nsIPrefBranch.PREF_INT:
+ expectedValue = Services.prefs.getIntPref(key);
+ break;
+ case Ci.nsIPrefBranch.PREF_BOOL:
+ expectedValue = Services.prefs.getBoolPref(key);
+ break;
+ default:
+ ok(false, "unexpected pref type (" + key + ")");
+ break;
+ }
+
+ is(prefs.allPrefs[key].value, expectedValue,
+ "valid preference value (" + key + ")");
+ is(prefs.allPrefs[key].hasUserValue, Services.prefs.prefHasUserValue(key),
+ "valid hasUserValue (" + key + ")");
+ });
+
+ ["test.bool", "test.int", "test.string"].forEach(function(key) {
+ ok(!prefs.allPrefs.hasOwnProperty(key), "expect no pref (" + key + ")");
+ is(Services.prefs.getPrefType(key), Ci.nsIPrefBranch.PREF_INVALID,
+ "pref (" + key + ") is clear");
+ });
+
+ client.close().then(() => {
+ DevToolsServer.destroy();
+ SimpleTest.finish();
+ });
+ }
+
+ function checkUndefined() {
+ let next = p.getCharPref("test.undefined");
+ next = next.then(
+ () => ok(false, "getCharPref should've thrown for an undefined preference"),
+ (ex) => {
+ const messageRe = new RegExp(
+ "Protocol error \\(Error\\): preference is not of the right type: " +
+ `test.undefined from: ${p.actorID} ` +
+ "\\(resource://devtools/server/actors/preference.js:\\d+:\\d+\\)"
+ );
+ ok(messageRe.test(ex.message), "Error message matches the expected format");
+ }
+ );
+ return next;
+ }
+
+ function updatePrefsProperty(key) {
+ return function(value) {
+ prefs[key] = value;
+ };
+ }
+
+ p.getAllPrefs().then(updatePrefsProperty("allPrefs"))
+ .then(() => p.setBoolPref("test.bool", localPref.boolPref))
+ .then(() => p.setIntPref("test.int", localPref.intPref))
+ .then(() => p.setCharPref("test.string", localPref.charPref))
+ .then(() => p.getBoolPref("test.bool")).then(updatePrefsProperty("boolPref"))
+ .then(() => p.getIntPref("test.int")).then(updatePrefsProperty("intPref"))
+ .then(() => p.getCharPref("test.string")).then(updatePrefsProperty("charPref"))
+ .then(() => p.clearUserPref("test.bool"))
+ .then(() => p.clearUserPref("test.int"))
+ .then(() => p.clearUserPref("test.string"))
+ .then(() => checkUndefined())
+ .then(checkValues);
+ });
+}
+
+window.onload = function() {
+ SpecialPowers.pushPrefEnv({
+ "set": [
+ ["test.all.bool", true],
+ ["test.all.int", 0x4321],
+ ["test.all.string", "allizom"],
+ ],
+ }, runTests);
+};
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_styles-applied.html b/devtools/server/tests/chrome/test_styles-applied.html
new file mode 100644
index 0000000000..0910e9d7bc
--- /dev/null
+++ b/devtools/server/tests/chrome/test_styles-applied.html
@@ -0,0 +1,155 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+let gStyles = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { commands, target } = await attachURL(url);
+
+ // We need an active resource command before initializing the inspector front.
+ const resourceCommand = commands.resourceCommand;
+ // We listen to any random resource, we only need to trigger the resource command
+ // onTargetAvailable callback so the `resourceCommand` attribute is set on the target front.
+ await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: () => {} });
+
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ gStyles = await inspector.getPageStyle();
+
+ runNextTest();
+});
+
+addTest(async function inheritedUserStyles() {
+ const node = await gWalker.querySelector(gWalker.rootNode, "#test-node")
+ const applied = await gStyles.getApplied(node, { inherited: true, filter: "user" });
+
+ ok(!applied[0].inherited, "Entry 0 should be uninherited");
+ is(applied[0].rule.type, 100, "Entry 0 should be an element style");
+ ok(!!applied[0].rule.href, "Element styles should have a URL");
+ is(applied[0].rule.cssText, "", "Entry 0 should be an empty style");
+
+ is(applied[1].inherited.id, "uninheritable-rule-inheritable-style",
+ "Entry 1 should be inherited from the parent");
+ is(applied[1].rule.type, 100, "Entry 1 should be an element style");
+ is(applied[1].rule.cssText, "color: red;",
+ "Entry 1 should have the expected cssText");
+
+ is(applied[2].inherited.id, "inheritable-rule-inheritable-style",
+ "Entry 2 should be inherited from the parent's parent");
+ is(applied[2].rule.type, 100, "Entry 2 should be an element style");
+ is(applied[2].rule.cssText, "color: blue;",
+ "Entry 2 should have the expected cssText");
+
+ is(applied[3].inherited.id, "inheritable-rule-inheritable-style",
+ "Entry 3 should be inherited from the parent's parent");
+ is(applied[3].rule.type, 1, "Entry 3 should be a rule style");
+ is(applied[3].rule.cssText, "font-size: 15px;",
+ "Entry 3 should have the expected cssText");
+ ok(!applied[3].matchedDesugaredSelectors,
+ "Shouldn't get matchedDesugaredSelectors with this request.");
+
+ is(applied[4].inherited.id, "inheritable-rule-uninheritable-style",
+ "Entry 4 should be inherited from the parent's parent");
+ is(applied[4].rule.type, 1, "Entry 4 should be an rule style");
+ is(applied[4].rule.cssText, "font-size: 15px;",
+ "Entry 4 should have the expected cssText");
+ ok(!applied[4].matchedDesugaredSelectors, "Shouldn't get matchedDesugaredSelectors with this request.");
+
+ is(applied.length, 5, "Should have 5 rules.");
+
+ runNextTest();
+});
+
+addTest(async function inheritedSystemStyles() {
+ const node = await gWalker.querySelector(gWalker.rootNode, "#test-node");
+ const applied = await gStyles.getApplied(node, { inherited: true, filter: "ua" });
+ // If our system stylesheets are prone to churn, this might be a fragile
+ // test. If you're here because of that I apologize, file a bug
+ // and we can find a different way to test.
+
+ ok(!applied[1].inherited, "Entry 1 should not be inherited");
+ ok(applied[1].rule.parentStyleSheet.system, "Entry 1 should be a system style");
+ is(applied[1].rule.type, 1, "Entry 1 should be a rule style");
+ is(applied.length, 9, "Should have the expected number of rules.");
+
+ runNextTest();
+});
+
+addTest(async function noInheritedStyles() {
+ const node = await gWalker.querySelector(gWalker.rootNode, "#test-node")
+ const applied = await gStyles.getApplied(node, { inherited: false, filter: "user" });
+ ok(!applied[0].inherited, "Entry 0 should be uninherited");
+ is(applied[0].rule.type, 100, "Entry 0 should be an element style");
+ is(applied[0].rule.cssText, "", "Entry 0 should be an empty style");
+ is(applied.length, 1, "Should have 1 rule.");
+
+ runNextTest();
+});
+
+addTest(async function matchedSelectors() {
+ const node = await gWalker.querySelector(gWalker.rootNode, "#test-node");
+ const applied = await gStyles.getApplied(node, {
+ inherited: true, filter: "user", matchedSelectors: true,
+ });
+ is(applied[3].matchedDesugaredSelectors[0], ".inheritable-rule",
+ "Entry 3 should have a matched selector");
+ is(applied[4].matchedDesugaredSelectors[0], ".inheritable-rule",
+ "Entry 4 should have a matched selector");
+
+ runNextTest();
+});
+
+addTest(async function testMediaQuery() {
+ const node = await gWalker.querySelector(gWalker.rootNode, "#mediaqueried")
+ const applied = await gStyles.getApplied(node, {
+ inherited: false,
+ filter: "user",
+ matchedSelectors: true,
+ });
+
+ const ruleWithMedia = applied[1].rule;
+ is(ruleWithMedia.type, 1, "Entry 1 is a rule style");
+ is(ruleWithMedia.ancestorData[0].type, "media", "Entry 1's rule ancestor data holds the media rule data...");
+ is(ruleWithMedia.ancestorData[0].value, "screen", "...with the expected value");
+
+ runNextTest();
+});
+
+addTest(function cleanup() {
+ gStyles = null;
+ gWalker = null;
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_styles-computed.html b/devtools/server/tests/chrome/test_styles-computed.html
new file mode 100644
index 0000000000..9aa962108a
--- /dev/null
+++ b/devtools/server/tests/chrome/test_styles-computed.html
@@ -0,0 +1,130 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+let gStyles = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ gStyles = await inspector.getPageStyle();
+ runNextTest();
+});
+
+addTest(function testComputed() {
+ promiseDone(
+ gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => {
+ return gStyles.getComputed(node, {});
+ }).then(computed => {
+ // Test a smattering of properties that include some system-defined
+ // props, some props that were defined in this node's stylesheet,
+ // and some default props.
+ is(computed["white-space-collapse"].value, "collapse", "Default value should appear");
+ is(computed.display.value, "block", "System stylesheet item should appear");
+ is(computed.cursor.value, "crosshair", "Included stylesheet rule should appear");
+ is(computed.color.value, "rgb(255, 0, 0)",
+ "Inherited style attribute should appear");
+ is(computed["font-size"].value, "15px", "Inherited inline rule should appear");
+
+ // We didn't request markMatched, so these shouldn't be set
+ ok(!computed.cursor.matched, "Didn't ask for matched, shouldn't get it");
+ ok(!computed.color.matched, "Didn't ask for matched, shouldn't get it");
+ ok(!computed["font-size"].matched, "Didn't ask for matched, shouldn't get it");
+ }).then(runNextTest)
+ );
+});
+
+addTest(function testComputedUserMatched() {
+ promiseDone(
+ gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => {
+ return gStyles.getComputed(node, { filter: "user", markMatched: true });
+ }).then(computed => {
+ ok(!computed["white-space-collapse"].matched, "Default style shouldn't match");
+ ok(!computed.display.matched, "Only user styles should match");
+ ok(computed.cursor.matched, "Asked for matched, should get it");
+ ok(computed.color.matched, "Asked for matched, should get it");
+ ok(computed["font-size"].matched, "Asked for matched, should get it");
+ }).then(runNextTest)
+ );
+});
+
+addTest(function testComputedSystemMatched() {
+ promiseDone(
+ gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => {
+ return gStyles.getComputed(node, { filter: "ua", markMatched: true });
+ }).then(computed => {
+ ok(!computed["white-space-collapse"].matched, "Default style shouldn't match");
+ ok(computed.display.matched, "System stylesheets should match");
+ ok(computed.cursor.matched, "Asked for matched, should get it");
+ ok(computed.color.matched, "Asked for matched, should get it");
+ ok(computed["font-size"].matched, "Asked for matched, should get it");
+ }).then(runNextTest)
+ );
+});
+
+addTest(function testComputedUserOnlyMatched() {
+ promiseDone(
+ gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => {
+ return gStyles.getComputed(node, { filter: "user", onlyMatched: true });
+ }).then(computed => {
+ ok(!("white-space-collapse" in computed), "Default style shouldn't exist");
+ ok(!("display" in computed), "System stylesheets shouldn't exist");
+ ok(("cursor" in computed), "User items should exist.");
+ ok(("color" in computed), "User items should exist.");
+ ok(("font-size" in computed), "User items should exist.");
+ }).then(runNextTest)
+ );
+});
+
+addTest(function testComputedSystemOnlyMatched() {
+ promiseDone(
+ gWalker.querySelector(gWalker.rootNode, "#computed-test-node").then(node => {
+ return gStyles.getComputed(node, { filter: "ua", onlyMatched: true });
+ }).then(computed => {
+ ok(!("white-space-collapse" in computed), "Default style shouldn't exist");
+ ok(("display" in computed), "System stylesheets should exist");
+ ok(("cursor" in computed), "User items should exist.");
+ ok(("color" in computed), "User items should exist.");
+ ok(("font-size" in computed), "User items should exist.");
+ }).then(runNextTest)
+ );
+});
+
+addTest(function cleanup() {
+ gStyles = null;
+ gWalker = null;
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_styles-layout.html b/devtools/server/tests/chrome/test_styles-layout.html
new file mode 100644
index 0000000000..f0441edd13
--- /dev/null
+++ b/devtools/server/tests/chrome/test_styles-layout.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+<title>Test for Bug 1175040 - PageStyleActor.getLayout</title>
+<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+<link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+<script type="application/javascript" src="inspector-helpers.js"></script>
+<script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+let gStyles = null;
+
+addTest(async function() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ gStyles = await inspector.getPageStyle();
+ runNextTest();
+});
+
+addTest(function() {
+ ok(gStyles.getLayout, "The PageStyleActor has a getLayout method");
+ runNextTest();
+});
+
+addAsyncTest(async function() {
+ const node = await gWalker.querySelector(gWalker.rootNode, "#layout-element");
+ const layout = await gStyles.getLayout(node, {});
+
+ const properties = ["width", "height",
+ "margin-top", "margin-right", "margin-bottom",
+ "margin-left", "padding-top", "padding-right",
+ "padding-bottom", "padding-left", "border-top-width",
+ "border-right-width", "border-bottom-width",
+ "border-left-width", "z-index", "box-sizing", "display",
+ "position"];
+ for (const prop of properties) {
+ ok((prop in layout), "The layout object returned has " + prop);
+ }
+
+ runNextTest();
+});
+
+addAsyncTest(async function() {
+ const node = await gWalker.querySelector(gWalker.rootNode, "#layout-element");
+ const layout = await gStyles.getLayout(node, {});
+
+ const expected = {
+ "box-sizing": "border-box",
+ "position": "absolute",
+ "z-index": "2",
+ "display": "block",
+ "width": 50,
+ "height": 50,
+ "margin-top": "10px",
+ "margin-right": "20px",
+ "margin-bottom": "30px",
+ "margin-left": "0px",
+ };
+
+ for (const name in expected) {
+ is(layout[name], expected[name], "The " + name + " property is correct");
+ }
+
+ runNextTest();
+});
+
+addAsyncTest(async function() {
+ const node = await gWalker.querySelector(gWalker.rootNode,
+ "#layout-auto-margin-element");
+
+ let layout = await gStyles.getLayout(node, {});
+ ok(!("autoMargins" in layout),
+ "By default, getLayout doesn't return auto margins");
+
+ layout = await gStyles.getLayout(node, {autoMargins: true});
+ ok(("autoMargins" in layout),
+ "getLayout does return auto margins when asked to");
+ is(layout.autoMargins.left, "auto", "The left margin is auto");
+ is(layout.autoMargins.right, "auto", "The right margin is auto");
+ ok(!layout.autoMargins.bottom, "The bottom margin is not auto");
+ ok(!layout.autoMargins.top, "The top margin is not auto");
+
+ runNextTest();
+});
+
+addTest(function() {
+ gStyles = gWalker = null;
+ runNextTest();
+});
+
+</script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1175040">Mozilla Bug 1175040</a>
+<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none"></div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_styles-matched.html b/devtools/server/tests/chrome/test_styles-matched.html
new file mode 100644
index 0000000000..42e1ec7885
--- /dev/null
+++ b/devtools/server/tests/chrome/test_styles-matched.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+const CssLogic = require("devtools/shared/inspector/css-logic");
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+let gStyles = null;
+let gInspectee = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { commands, target, doc } = await attachURL(url);
+ gInspectee = doc;
+
+ // We need an active resource command before initializing the inspector front.
+ const resourceCommand = commands.resourceCommand;
+ // We listen to any random resource, we only need to trigger the resource command
+ // onTargetAvailable callback so the `resourceCommand` attribute is set on the target front.
+ await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: () => {} });
+
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ gStyles = await inspector.getPageStyle();
+ runNextTest();
+});
+
+addTest(function testMatchedStyles() {
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#matched-test-node").then(node => {
+ return gStyles.getMatchedSelectors(node, "font-size", {});
+ }).then(matched => {
+ is(matched[0].sourceText, "this.style", "First match comes from the element style");
+ is(matched[0].selector, "@element.style", "Element style has a special selector");
+ is(matched[0].value, "10px", "First match has the expected value");
+ is(matched[0].status, CssLogic.STATUS.BEST, "First match is the best match");
+ is(matched[0].rule.type, 100, "First match is an element style");
+ is(matched[0].rule.href, gInspectee.defaultView.location.href,
+ "Node style comes from this document");
+
+ is(matched[1].sourceText, ".column-rule",
+ "Second match comes from a rule");
+ is(matched[1].selector, ".column-rule",
+ "Second match comes from highest line number");
+ is(matched[1].value, "25px", "Second match comes from highest column");
+ is(matched[1].status, CssLogic.STATUS.PARENT_MATCH,
+ "Second match is from the parent");
+ is(matched[1].rule.parentStyleSheet.href, null,
+ "Inline stylesheet shouldn't have an href");
+ is(matched[1].rule.parentStyleSheet.nodeHref, gInspectee.defaultView.location.href,
+ "Inline stylesheet's nodeHref should match the current document");
+ ok(!matched[1].rule.parentStyleSheet.system,
+ "Inline stylesheet shouldn't be a system stylesheet.");
+
+ // matched[2] is only there to test matched[1]; do not need to test
+
+ is(matched[3].value, "15px", "Third match has the expected value");
+ }).then(runNextTest));
+});
+
+addTest(function testSystemStyles() {
+ let testNode = null;
+
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#matched-test-node").then(node => {
+ testNode = node;
+ return gStyles.getMatchedSelectors(testNode, "display", { filter: "user" });
+ }).then(matched => {
+ is(matched.length, 0, "No user selectors apply to this rule.");
+ return gStyles.getMatchedSelectors(testNode, "display", { filter: "ua" });
+ }).then(matched => {
+ is(matched[0].selector, "div", "Should match system div selector");
+ is(matched[0].value, "block");
+ }).then(runNextTest));
+});
+
+addTest(function cleanup() {
+ gStyles = null;
+ gWalker = null;
+ gInspectee = null;
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_styles-modify.html b/devtools/server/tests/chrome/test_styles-modify.html
new file mode 100644
index 0000000000..e615ec4425
--- /dev/null
+++ b/devtools/server/tests/chrome/test_styles-modify.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+const {isCssPropertyKnown} = require("devtools/server/actors/css-properties");
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+var gWalker = null;
+var gStyles = null;
+var gInspectee = null;
+
+addAsyncTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+
+ const { target, doc } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gInspectee = doc;
+
+ gWalker = inspector.walker;
+ gStyles = await inspector.getPageStyle();
+
+ runNextTest();
+});
+
+addAsyncTest(async function modifyProperties() {
+ const localNode = gInspectee.querySelector("#inheritable-rule-inheritable-style");
+
+ const node = await gWalker.querySelector(gWalker.rootNode,
+ "#inheritable-rule-inheritable-style");
+
+ const applied = await gStyles.getApplied(node,
+ { inherited: false, filter: "user" });
+
+ const elementStyle = applied[0].rule;
+ is(elementStyle.cssText, localNode.style.cssText, "Got expected css text");
+
+ // Change an existing property...
+ await setProperty(elementStyle, 0, "color", "black");
+ // Create a new property
+ await setProperty(elementStyle, 1, "background-color", "green");
+
+ // Create a new property and then change it immediately.
+ await setProperty(elementStyle, 2, "border", "1px solid black");
+ await setProperty(elementStyle, 2, "border", "2px solid black");
+
+ is(elementStyle.cssText,
+ "color: black; background-color: green; border: 2px solid black;",
+ "Should have expected cssText");
+ is(elementStyle.cssText, localNode.style.cssText,
+ "Local node and style front match.");
+
+ // Remove all the properties
+ await removeProperty(elementStyle, 0, "color");
+ await removeProperty(elementStyle, 0, "background-color");
+ await removeProperty(elementStyle, 0, "border");
+
+ is(elementStyle.cssText, "", "Should have expected cssText");
+ is(elementStyle.cssText, localNode.style.cssText,
+ "Local node and style front match.");
+
+ runNextTest();
+});
+
+async function setProperty(rule, index, name, value) {
+ const changes = rule.startModifyingProperties(isCssPropertyKnown);
+ changes.setProperty(index, name, value);
+ await changes.apply();
+}
+
+async function removeProperty(rule, index, name) {
+ const changes = rule.startModifyingProperties(isCssPropertyKnown);
+ changes.removeProperty(index, name);
+ await changes.apply();
+}
+
+addTest(function cleanup() {
+ gStyles = null;
+ gWalker = null;
+ gInspectee = null;
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=">Mozilla Bug </a>
+<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_styles-svg.html b/devtools/server/tests/chrome/test_styles-svg.html
new file mode 100644
index 0000000000..b03bc868b7
--- /dev/null
+++ b/devtools/server/tests/chrome/test_styles-svg.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=921191
+Bug 921191 - allow inspection/editing of SVG elements' CSS properties
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug </title>
+
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="inspector-helpers.js"></script>
+ <script type="application/javascript">
+"use strict";
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+ runNextTest();
+};
+
+let gWalker = null;
+let gStyles = null;
+
+addTest(async function setup() {
+ const url = document.getElementById("inspectorContent").href;
+ const { target } = await attachURL(url);
+ const inspector = await target.getFront("inspector");
+ gWalker = inspector.walker;
+ gStyles = await inspector.getPageStyle();
+ runNextTest();
+});
+
+addTest(function inheritedUserStyles() {
+ promiseDone(gWalker.querySelector(gWalker.rootNode, "#svgcontent rect").then(node => {
+ return gStyles.getApplied(node, { inherited: true, filter: "user" });
+ }).then(applied => {
+ is(applied.length, 2, "Should have 2 rules");
+ is(applied[1].rule.cssText, "fill: rgb(1, 2, 3);", "cssText is right");
+ }).then(runNextTest));
+});
+
+addTest(function cleanup() {
+ gStyles = null;
+ gWalker = null;
+ runNextTest();
+});
+
+ </script>
+</head>
+<body>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=921191">Mozilla Bug 921191</a>
+<a id="inspectorContent" target="_blank" href="inspector-styles-data.html">Test Document</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_suspendTimeouts.html b/devtools/server/tests/chrome/test_suspendTimeouts.html
new file mode 100644
index 0000000000..65a168986f
--- /dev/null
+++ b/devtools/server/tests/chrome/test_suspendTimeouts.html
@@ -0,0 +1,20 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=1426467
+
+When we use windowUtils.resumeTimeouts to resume timeouts in a window, that call
+should not immediately dispatch `onmessage` handlers for messages from workers.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Mozilla Bug 1426467</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src='test_suspendTimeouts.js'></script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_suspendTimeouts.js b/devtools/server/tests/chrome/test_suspendTimeouts.js
new file mode 100644
index 0000000000..614ac60cdb
--- /dev/null
+++ b/devtools/server/tests/chrome/test_suspendTimeouts.js
@@ -0,0 +1,139 @@
+"use strict";
+
+// The debugger uses nsIDOMWindowUtils::suspendTimeouts and ...::resumeTimeouts
+// to ensure that content event handlers do not run while a JavaScript
+// invocation is stepping or paused at a breakpoint. If a worker thread sends
+// messages to the content while the content is paused, those messages must not
+// run until the JavaScript invocation interrupted by the debugger has completed.
+//
+// Bug 1426467 is that calling nsIDOMWindowUtils::resumeTimeouts actually
+// delivers deferred messages itself, calling the content's 'onmessage' handler.
+// But the debugger calls suspend/resume around each individual interruption of
+// the debuggee -- each step, say -- meaning that hitting the "step into" button
+// causes you to step from the debuggee directly into an onmessage handler,
+// since the onmessage handler is the next function call the debugger sees.
+//
+// In other words, delivering deferred messages from resumeTimeouts, as it is
+// used by the debugger, breaks the run-to-completion rule. They must not be
+// delivered until after the JavaScript invocation at hand is complete. That's
+// what this test checks.
+//
+// For this test to detect the bug, the following steps must take place in
+// order:
+//
+// 1) The content page must call suspendTimeouts.
+// 2) A runnable conveying a message from the worker thread must attempt to
+// deliver the message, see that the content page has suspended such things,
+// and hold the message for later delivery.
+// 3) The content page must call resumeTimeouts.
+//
+// In a correct implementation, the message from the worker thread is delivered
+// only after the main thread returns to the event loop after calling
+// resumeTimeouts in step 3). In the buggy implementation, the onmessage handler
+// is called directly from the call to resumeTimeouts, so that the onmessage
+// handlers run in the midst of whatever JavaScript invocation resumed timeouts
+// (say, stepping in the debugger), in violation of the run-to-completion rule.
+//
+// In this specific bug, the handlers are called from resumeTimeouts, but
+// really, running them any time before that invocation returns to the main
+// event loop would be a bug.
+//
+// Posting the message and calling resumeTimeouts take place in different
+// threads, but if 2) and 3) don't occur in that order, the worker's message
+// will never be delayed and the test will pass spuriously. But the worker
+// can't communicate with the content page directly, to let it know that it
+// should proceed with step 3): the purpose of suspendTimeouts is to pause
+// all such communication.
+//
+// So instead, the content page creates a MessageChannel, and passes one
+// MessagePort to the worker and the other to this mochitest (which has its
+// own window, separate from the one calling suspendTimeouts). The worker
+// notifies the mochitest when it has posted the message, and then the
+// mochitest calls into the content to carry out step 3).
+
+// To help you follow all the callbacks and event handlers, this code pulls out
+// event handler functions so that control flows from top to bottom.
+
+window.onload = function () {
+ // This mochitest is not complete until we call SimpleTest.finish. Don't just
+ // exit as soon as we return to the main event loop.
+ SimpleTest.waitForExplicitFinish();
+
+ const iframe = document.createElement("iframe");
+ iframe.src =
+ "http://mochi.test:8888/chrome/devtools/server/tests/chrome/suspendTimeouts_content.html";
+ iframe.onload = iframe_onload_handler;
+ document.body.appendChild(iframe);
+
+ function iframe_onload_handler() {
+ const content = iframe.contentWindow.wrappedJSObject;
+
+ const windowUtils = iframe.contentWindow.windowUtils;
+
+ // Hand over the suspend and resume functions to the content page, along
+ // with some testing utilities.
+ content.suspendTimeouts = function () {
+ SimpleTest.info("test_suspendTimeouts", "calling suspendTimeouts");
+ windowUtils.suspendTimeouts();
+ };
+ content.resumeTimeouts = function () {
+ windowUtils.resumeTimeouts();
+ SimpleTest.info("test_suspendTimeouts", "resumeTimeouts called");
+ };
+ content.info = function (message) {
+ SimpleTest.info("suspendTimeouts_content.js", message);
+ };
+ content.ok = SimpleTest.ok;
+ content.finish = finish;
+
+ SimpleTest.info(
+ "Disappointed with National Tautology Day? Well, it is what it is."
+ );
+
+ // Once the worker has sent a message to its parent (which should get delayed),
+ // it sends us a message directly on this channel.
+ const workerPort = content.create_channel();
+ workerPort.onmessage = handle_worker_echo;
+
+ // Have content send the worker a message that it should echo back to both
+ // content and us. The echo to content should get delayed; the echo to us
+ // should cause our handle_worker_echo to be called.
+ content.start_worker();
+
+ function handle_worker_echo({ data }) {
+ info(`mochitest received message from worker: ${data}`);
+
+ // As it turns out, it's not correct to assume that, if the worker posts a
+ // message to its parent via the global `postMessage` function, and then
+ // posts a message to the mochitest via the MessagePort, those two
+ // messages will be delivered in the order they were sent.
+ //
+ // - Messages sent via the worker's global's postMessage go through two
+ // ThrottledEventQueues (one in the worker, and one on the parent), and
+ // eventually find their way into the thread's primary event queue,
+ // which is a PrioritizedEventQueue.
+ //
+ // - Messages sent via a MessageChannel whose ports are owned by different
+ // threads are passed as IPDL messages.
+ //
+ // There's basically no reliable way to ensure that delivery to content
+ // has been attempted and the runnable deferred; there are too many
+ // variables affecting the order in which things are processed. Delaying
+ // for a second is the best I could think of.
+ //
+ // Fortunately, this tactic failing can only cause spurious test passes
+ // (the runnable never gets deferred, so things work by accident), not
+ // spurious failures. Without some kind of trustworthy notification that
+ // the runnable has been deferred, perhaps via some special white-box
+ // testing API, we can't do better.
+ setTimeout(() => {
+ content.resume_timeouts();
+ }, 1000);
+ }
+
+ function finish(message) {
+ SimpleTest.info("suspendTimeouts_content.js", "called finish");
+ SimpleTest.finish();
+ }
+ }
+};
diff --git a/devtools/server/tests/chrome/test_unsafeDereference.html b/devtools/server/tests/chrome/test_unsafeDereference.html
new file mode 100644
index 0000000000..eca1e7d43e
--- /dev/null
+++ b/devtools/server/tests/chrome/test_unsafeDereference.html
@@ -0,0 +1,53 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=837723
+
+When we use Debugger.Object.prototype.unsafeDereference to get a non-D.O
+reference to a content object in chrome, that reference should be via an
+xray wrapper.
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Mozilla Bug 837723</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script>
+"use strict";
+
+const {addDebuggerToGlobal} = ChromeUtils.importESModule("resource://gre/modules/jsdebugger.sys.mjs");
+addDebuggerToGlobal(globalThis);
+
+window.onload = function() {
+ SimpleTest.waitForExplicitFinish();
+
+ const iframe = document.createElement("iframe");
+ iframe.src = "http://mochi.test:8888/chrome/devtools/server/tests/chrome/nonchrome_unsafeDereference.html";
+
+ iframe.onload = function() {
+ const dbg = new Debugger();
+ const contentDO = dbg.addDebuggee(iframe.contentWindow);
+ const xhrDesc = contentDO.getOwnPropertyDescriptor("xhr");
+
+ isnot(xhrDesc, undefined, "xhr should be visible as property of content global");
+ isnot(xhrDesc.value, undefined, "xhr should have a value");
+
+ const xhr = xhrDesc.value.unsafeDereference();
+
+ is(typeof xhr, "object", "we should be able to deference xhr's value's D.O");
+ is(xhr.timeout, 1742, "chrome should see the xhr's 'timeout' property");
+ is(xhr.expando, undefined, "chrome should not see the xhr's 'expando' property");
+
+ SimpleTest.finish();
+ };
+
+ document.body.appendChild(iframe);
+};
+
+</script>
+</pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/test_webconsole-node-grip.html b/devtools/server/tests/chrome/test_webconsole-node-grip.html
new file mode 100644
index 0000000000..0c54f65964
--- /dev/null
+++ b/devtools/server/tests/chrome/test_webconsole-node-grip.html
@@ -0,0 +1,66 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>DOMNode Object actor test</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+ <script type="application/javascript" src="webconsole-helpers.js"></script>
+ <script>
+"use strict";
+
+const TEST_URL = "data:text/html,<html><body>Hello</body></html>";
+
+window.onload = async function() {
+ SimpleTest.waitForExplicitFinish();
+
+ try {
+ const commands = await addTabAndCreateCommands(TEST_URL);
+ await testNotInTreeElementNode(commands);
+ await testInTreeElementNode(commands);
+ await testNotInTreeTextNode(commands);
+ await testInTreeTextNode(commands);
+ } catch (e) {
+ ok(false, `Error thrown: ${e.message}`);
+ }
+ SimpleTest.finish();
+};
+
+async function testNotInTreeElementNode(commands) {
+ info("Testing isConnected property on a ElementNode not in the DOM tree");
+ const {result} = await commands.scriptCommand.execute("document.createElement(\"div\")");
+ is(result.getGrip().preview.isConnected, false,
+ "isConnected is false since we only created the element");
+}
+
+async function testInTreeElementNode(commands) {
+ info("Testing isConnected property on a ElementNode in the DOM tree");
+ const {result} = await commands.scriptCommand.execute("document.body");
+ is(result.getGrip().preview.isConnected, true,
+ "isConnected is true as expected, since the element was retrieved from the DOM tree");
+}
+
+async function testNotInTreeTextNode(commands) {
+ info("Testing isConnected property on a TextNode not in the DOM tree");
+ const {result} = await commands.scriptCommand.execute("document.createTextNode(\"Hello\")");
+ is(result.getGrip().preview.isConnected, false,
+ "isConnected is false since we only created the element");
+}
+
+async function testInTreeTextNode(commands) {
+ info("Testing isConnected property on a TextNode in the DOM tree");
+ const {result} = await commands.scriptCommand.execute("document.body.firstChild");
+ is(result.getGrip().preview.isConnected, true,
+ "isConnected is true as expected, since the element was retrieved from the DOM tree");
+}
+
+ </script>
+</head>
+<body>
+ <p id="display"></p>
+ <div id="content" style="display: none">
+ </div>
+ <pre id="test">
+ </pre>
+</body>
+</html>
diff --git a/devtools/server/tests/chrome/webconsole-helpers.js b/devtools/server/tests/chrome/webconsole-helpers.js
new file mode 100644
index 0000000000..8be8554e35
--- /dev/null
+++ b/devtools/server/tests/chrome/webconsole-helpers.js
@@ -0,0 +1,54 @@
+/* exported addTabAndCreateCommands */
+"use strict";
+
+const { require } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+);
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const {
+ CommandsFactory,
+} = require("resource://devtools/shared/commands/commands-factory.js");
+
+// Always log packets when running tests.
+Services.prefs.setBoolPref("devtools.debugger.log", true);
+SimpleTest.registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("devtools.debugger.log");
+});
+
+if (!DevToolsServer.initialized) {
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+ SimpleTest.registerCleanupFunction(function () {
+ DevToolsServer.destroy();
+ });
+}
+
+/**
+ * Open a tab, load the url, find the tab with the devtools server,
+ * and attach the console to it.
+ *
+ * @param {string} url : url to navigate to
+ * @return {Promise} Promise resolving when commands are initialized
+ * The Promise resolves with the commands.
+ */
+async function addTabAndCreateCommands(url) {
+ const tab = await addTab(url);
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+ return commands;
+}
+
+/**
+ * Naive implementaion of addTab working from a mochitest-chrome test.
+ */
+async function addTab(url) {
+ const { gBrowser } = Services.wm.getMostRecentWindow("navigator:browser");
+ const { BrowserTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/BrowserTestUtils.sys.mjs"
+ );
+ const tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url));
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ return tab;
+}
diff --git a/devtools/server/tests/xpcshell/.eslintrc.js b/devtools/server/tests/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..b3d0382a56
--- /dev/null
+++ b/devtools/server/tests/xpcshell/.eslintrc.js
@@ -0,0 +1,9 @@
+"use strict";
+
+module.exports = {
+ // Extend from the common devtools xpcshell eslintrc config.
+ extends: "../../../.eslintrc.xpcshell.js",
+ rules: {
+ "no-debugger": 0,
+ },
+};
diff --git a/devtools/server/tests/xpcshell/addons/web-extension-upgrade/manifest.json b/devtools/server/tests/xpcshell/addons/web-extension-upgrade/manifest.json
new file mode 100644
index 0000000000..cad9442b80
--- /dev/null
+++ b/devtools/server/tests/xpcshell/addons/web-extension-upgrade/manifest.json
@@ -0,0 +1,10 @@
+{
+ "manifest_version": 2,
+ "name": "Test Addons Actor Upgrade",
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "test-addons-actor@mozilla.org"
+ }
+ }
+}
diff --git a/devtools/server/tests/xpcshell/addons/web-extension/manifest.json b/devtools/server/tests/xpcshell/addons/web-extension/manifest.json
new file mode 100644
index 0000000000..47f07671e5
--- /dev/null
+++ b/devtools/server/tests/xpcshell/addons/web-extension/manifest.json
@@ -0,0 +1,10 @@
+{
+ "manifest_version": 2,
+ "name": "Test Addons Actor",
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "test-addons-actor@mozilla.org"
+ }
+ }
+}
diff --git a/devtools/server/tests/xpcshell/addons/web-extension2/manifest.json b/devtools/server/tests/xpcshell/addons/web-extension2/manifest.json
new file mode 100644
index 0000000000..e1ba91f4fb
--- /dev/null
+++ b/devtools/server/tests/xpcshell/addons/web-extension2/manifest.json
@@ -0,0 +1,10 @@
+{
+ "manifest_version": 2,
+ "name": "Test Addons Actor 2",
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "test-addons-actor2@mozilla.org"
+ }
+ }
+}
diff --git a/devtools/server/tests/xpcshell/completions.js b/devtools/server/tests/xpcshell/completions.js
new file mode 100644
index 0000000000..5e77e4e886
--- /dev/null
+++ b/devtools/server/tests/xpcshell/completions.js
@@ -0,0 +1,23 @@
+"use strict";
+/* exported global doRet doThrow */
+
+function ret() {
+ return 2;
+}
+
+function throws() {
+ throw new Error("yo");
+}
+
+function doRet() {
+ debugger;
+ const r = ret();
+ return r;
+}
+
+function doThrow() {
+ debugger;
+ try {
+ throws();
+ } catch (e) {}
+}
diff --git a/devtools/server/tests/xpcshell/head_dbg.js b/devtools/server/tests/xpcshell/head_dbg.js
new file mode 100644
index 0000000000..7161d5eaea
--- /dev/null
+++ b/devtools/server/tests/xpcshell/head_dbg.js
@@ -0,0 +1,984 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint no-unused-vars: ["error", {"vars": "local"}] */
+/* eslint-disable no-shadow */
+
+"use strict";
+var CC = Components.Constructor;
+
+// Populate AppInfo before anything (like the shared loader) accesses
+// System.appinfo, which is a lazy getter.
+const appInfo = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+appInfo.updateAppInfo({
+ ID: "devtools@tests.mozilla.org",
+ name: "devtools-tests",
+ version: "1",
+ platformVersion: "42",
+ crashReporter: true,
+});
+
+const { require, loader } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/Loader.sys.mjs"
+);
+const { worker } = ChromeUtils.import(
+ "resource://devtools/shared/loader/worker-loader.js"
+);
+
+const { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+// Always log packets when running tests. runxpcshelltests.py will throw
+// the output away anyway, unless you give it the --verbose flag.
+Services.prefs.setBoolPref("devtools.debugger.log", false);
+// Enable remote debugging for the relevant tests.
+Services.prefs.setBoolPref("devtools.debugger.remote-enabled", true);
+
+const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js");
+const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
+const {
+ ActorRegistry,
+} = require("resource://devtools/server/actors/utils/actor-registry.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const { DevToolsServer: WorkerDevToolsServer } = worker.require(
+ "resource://devtools/server/devtools-server.js"
+);
+const {
+ DevToolsClient,
+} = require("resource://devtools/client/devtools-client.js");
+const { ObjectFront } = require("resource://devtools/client/fronts/object.js");
+const {
+ LongStringFront,
+} = require("resource://devtools/client/fronts/string.js");
+const {
+ createCommandsDictionary,
+} = require("resource://devtools/shared/commands/index.js");
+const {
+ CommandsFactory,
+} = require("resource://devtools/shared/commands/commands-factory.js");
+
+const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { getAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
+ Ci.nsIPrincipal
+);
+
+var { loadSubScript, loadSubScriptWithOptions } = Services.scriptloader;
+
+/**
+ * The logic here must resemble the logic of --start-debugger-server as closely
+ * as possible. DevToolsStartup.sys.mjs uses a distinct loader that results in
+ * the existence of two isolated module namespaces. In practice, this can cause
+ * bugs such as bug 1837185.
+ */
+function getDistinctDevToolsServer() {
+ const {
+ useDistinctSystemPrincipalLoader,
+ releaseDistinctSystemPrincipalLoader,
+ } = ChromeUtils.importESModule(
+ "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs"
+ );
+ const requester = {};
+ const distinctLoader = useDistinctSystemPrincipalLoader(requester);
+ registerCleanupFunction(() => {
+ releaseDistinctSystemPrincipalLoader(requester);
+ });
+
+ const { DevToolsServer: DistinctDevToolsServer } = distinctLoader.require(
+ "resource://devtools/server/devtools-server.js"
+ );
+ return DistinctDevToolsServer;
+}
+
+/**
+ * Initializes any test that needs to work with add-ons.
+ *
+ * Should be called once per test script that needs to use AddonTestUtils (and
+ * not once per test task!).
+ */
+async function startupAddonsManager() {
+ // Create a directory for extensions.
+ const profileDir = do_get_profile().clone();
+ profileDir.append("extensions");
+
+ AddonTestUtils.init(globalThis);
+ AddonTestUtils.overrideCertDB();
+ AddonTestUtils.appInfo = getAppInfo();
+
+ await AddonTestUtils.promiseStartupManager();
+}
+
+async function createTargetForFakeTab(title) {
+ const client = await startTestDevToolsServer(title);
+
+ const tabs = await listTabs(client);
+ const tabDescriptor = findTab(tabs, title);
+
+ // These xpcshell tests use mocked actors (xpcshell-test/testactors)
+ // which still don't support watcher actor.
+ // Because of that we still can't enable server side targets and target swiching.
+ tabDescriptor.disableTargetSwitching();
+
+ return tabDescriptor.getTarget();
+}
+
+async function createTargetForMainProcess() {
+ const commands = await CommandsFactory.forMainProcess();
+ return commands.descriptorFront.getTarget();
+}
+
+/**
+ * Create a MemoryFront for a fake test tab.
+ */
+async function createTabMemoryFront() {
+ const target = await createTargetForFakeTab("test_memory");
+
+ // MemoryFront requires the HeadSnapshotActor actor to be available
+ // as a global actor. This isn't registered by startTestDevToolsServer which
+ // only register the target actors and not the browser ones.
+ DevToolsServer.registerActors({ browser: true });
+
+ const memoryFront = await target.getFront("memory");
+ await memoryFront.attach();
+
+ registerCleanupFunction(async () => {
+ await memoryFront.detach();
+
+ // On XPCShell, the target isn't for a local tab and so target.destroy
+ // won't close the client. So do it so here. It will automatically destroy the target.
+ await target.client.close();
+ });
+
+ return { target, memoryFront };
+}
+
+/**
+ * Same as createTabMemoryFront but attaches the MemoryFront to the MemoryActor
+ * scoped to the full runtime rather than to a tab.
+ */
+async function createMainProcessMemoryFront() {
+ const target = await createTargetForMainProcess();
+
+ const memoryFront = await target.getFront("memory");
+ await memoryFront.attach();
+
+ registerCleanupFunction(async () => {
+ await memoryFront.detach();
+ // For XPCShell, the main process target actor is ContentProcessTargetActor
+ // which doesn't expose any `detach` method. So that the target actor isn't
+ // destroyed when calling target.destroy.
+ // Close the client to cleanup everything.
+ await target.client.close();
+ });
+
+ return { client: target.client, memoryFront };
+}
+
+function createLongStringFront(conn, form) {
+ // CAUTION -- do not replicate in the codebase. Instead, use marshalling
+ // This code is simulating how the LongStringFront would be created by protocol.js
+ // We should not use it like this in the codebase, this is done only for testing
+ // purposes until we can return a proper LongStringFront from the server.
+ const front = new LongStringFront(conn, form);
+ front.actorID = form.actor;
+ front.manage(front);
+ return front;
+}
+
+function createTestGlobal(name, options) {
+ const principal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
+ Ci.nsIPrincipal
+ );
+ // NOTE: The Sandbox constructor behaves differently based on the argument
+ // length.
+ const sandbox = options
+ ? Cu.Sandbox(principal, options)
+ : Cu.Sandbox(principal);
+ sandbox.__name = name;
+ // Expose a few mocks to better represent a Window object.
+ // These attributes will be used by DOCUMENT_EVENT resource listener.
+ sandbox.performance = { timing: {} };
+ sandbox.document = {
+ readyState: "complete",
+ defaultView: sandbox,
+ };
+ return sandbox;
+}
+
+function connect(client) {
+ dump("Connecting client.\n");
+ return client.connect();
+}
+
+function close(client) {
+ dump("Closing client.\n");
+ return client.close();
+}
+
+function listTabs(client) {
+ dump("Listing tabs.\n");
+ return client.mainRoot.listTabs();
+}
+
+function findTab(tabs, title) {
+ dump("Finding tab with title '" + title + "'.\n");
+ for (const tab of tabs) {
+ if (tab.title === title) {
+ return tab;
+ }
+ }
+ return null;
+}
+
+function waitForNewSource(threadFront, url) {
+ dump("Waiting for new source with url '" + url + "'.\n");
+ return waitForEvent(threadFront, "newSource", function (packet) {
+ return packet.source.url === url;
+ });
+}
+
+function attachThread(targetFront, options = {}) {
+ dump("Attaching to thread.\n");
+ return targetFront.attachThread(options);
+}
+
+function resume(threadFront) {
+ dump("Resuming thread.\n");
+ return threadFront.resume();
+}
+
+async function addWatchpoint(threadFront, frame, variable, property, type) {
+ const path = `${variable}.${property}`;
+ info(`Add an ${path} ${type} watchpoint`);
+ const environment = await frame.getEnvironment();
+ const obj = environment.bindings.variables[variable];
+ const objFront = threadFront.pauseGrip(obj.value);
+ return objFront.addWatchpoint(property, path, type);
+}
+
+function getSources(threadFront) {
+ dump("Getting sources.\n");
+ return threadFront.getSources();
+}
+
+function findSource(sources, url) {
+ dump("Finding source with url '" + url + "'.\n");
+ for (const source of sources) {
+ if (source.url === url) {
+ return source;
+ }
+ }
+ return null;
+}
+
+function waitForPause(threadFront) {
+ dump("Waiting for pause.\n");
+ return waitForEvent(threadFront, "paused");
+}
+
+function waitForProperty(dbg, property) {
+ return new Promise(resolve => {
+ Object.defineProperty(dbg, property, {
+ set(newValue) {
+ resolve(newValue);
+ },
+ });
+ });
+}
+
+function setBreakpoint(threadFront, location) {
+ dump("Setting breakpoint.\n");
+ return threadFront.setBreakpoint(location, {});
+}
+
+function getPrototypeAndProperties(objClient) {
+ dump("getting prototype and properties.\n");
+
+ return objClient.getPrototypeAndProperties();
+}
+
+function dumpn(msg) {
+ dump("DBG-TEST: " + msg + "\n");
+}
+
+function testExceptionHook(ex) {
+ try {
+ do_report_unexpected_exception(ex);
+ } catch (e) {
+ return { throw: e };
+ }
+ return undefined;
+}
+
+// Convert an nsIScriptError 'logLevel' value into an appropriate string.
+function scriptErrorLogLevel(message) {
+ switch (message.logLevel) {
+ case Ci.nsIConsoleMessage.info:
+ return "info";
+ case Ci.nsIConsoleMessage.warn:
+ return "warning";
+ default:
+ Assert.equal(message.logLevel, Ci.nsIConsoleMessage.error);
+ return "error";
+ }
+}
+
+// Register a console listener, so console messages don't just disappear
+// into the ether.
+var errorCount = 0;
+var listener = {
+ observe(message) {
+ try {
+ let string;
+ errorCount++;
+ try {
+ // If we've been given an nsIScriptError, then we can print out
+ // something nicely formatted, for tools like Emacs to pick up.
+ message.QueryInterface(Ci.nsIScriptError);
+ dumpn(
+ message.sourceName +
+ ":" +
+ message.lineNumber +
+ ": " +
+ scriptErrorLogLevel(message) +
+ ": " +
+ message.errorMessage
+ );
+ string = message.errorMessage;
+ } catch (e1) {
+ // Be a little paranoid with message, as the whole goal here is to lose
+ // no information.
+ try {
+ string = "" + message.message;
+ } catch (e2) {
+ string = "<error converting error message to string>";
+ }
+ }
+
+ // Make sure we exit all nested event loops so that the test can finish.
+ while (
+ DevToolsServer &&
+ DevToolsServer.xpcInspector &&
+ DevToolsServer.xpcInspector.eventLoopNestLevel > 0
+ ) {
+ DevToolsServer.xpcInspector.exitNestedEventLoop();
+ }
+
+ // In the world before bug 997440, exceptions were getting lost because of
+ // the arbitrary JSContext being used in nsXPCWrappedJS::CallMethod.
+ // In the new world, the wanderers have returned. However, because of the,
+ // currently very-broken, exception reporting machinery in
+ // nsXPCWrappedJS these get reported as errors to the console, even if
+ // there's actually JS on the stack above that will catch them. If we
+ // throw an error here because of them our tests start failing. So, we'll
+ // just dump the message to the logs instead, to make sure the information
+ // isn't lost.
+ dumpn("head_dbg.js observed a console message: " + string);
+ } catch (_) {
+ // Swallow everything to avoid console reentrancy errors. We did our best
+ // to log above, but apparently that didn't cut it.
+ }
+ },
+};
+
+Services.console.registerListener(listener);
+
+function addTestGlobal(name, server = DevToolsServer) {
+ const global = createTestGlobal(name);
+ server.addTestGlobal(global);
+ return global;
+}
+
+// List the DevToolsClient |client|'s tabs, look for one whose title is
+// |title|.
+async function getTestTab(client, title) {
+ const tabs = await client.mainRoot.listTabs();
+ for (const tab of tabs) {
+ if (tab.title === title) {
+ return tab;
+ }
+ }
+ return null;
+}
+/**
+ * Attach to the client's tab whose title is specified
+ * @param {Object} client
+ * @param {Object} title
+ * @returns commands
+ */
+async function attachTestTab(client, title) {
+ const descriptorFront = await getTestTab(client, title);
+
+ // These xpcshell tests use mocked actors (xpcshell-test/testactors)
+ // which still don't support watcher actor.
+ // Because of that we still can't enable server side targets and target swiching.
+ descriptorFront.disableTargetSwitching();
+
+ const commands = await createCommandsDictionary(descriptorFront);
+ await commands.targetCommand.startListening();
+ return commands;
+}
+
+/**
+ * Attach to the client's tab whose title is specified, and then attach to
+ * that tab's thread.
+ * @param {Object} client
+ * @param {Object} title
+ * @returns {Object}
+ * targetFront
+ * threadFront
+ * commands
+ */
+async function attachTestThread(client, title) {
+ const commands = await attachTestTab(client, title);
+ const targetFront = commands.targetCommand.targetFront;
+ const threadFront = await targetFront.getFront("thread");
+ await targetFront.attachThread({
+ autoBlackBox: true,
+ });
+ Assert.equal(threadFront.state, "attached", "Thread front is attached");
+ return { targetFront, threadFront, commands };
+}
+
+/**
+ * Initialize the testing devtools server.
+ */
+function initTestDevToolsServer(server = DevToolsServer) {
+ if (server === WorkerDevToolsServer) {
+ const { createRootActor } = worker.require("xpcshell-test/testactors");
+ server.setRootActor(createRootActor);
+ } else {
+ const { createRootActor } = require("xpcshell-test/testactors");
+ server.setRootActor(createRootActor);
+ }
+
+ // Allow incoming connections.
+ server.init(function () {
+ return true;
+ });
+}
+
+/**
+ * Initialize the testing devtools server with a tab whose title is |title|.
+ */
+async function startTestDevToolsServer(title, server = DevToolsServer) {
+ initTestDevToolsServer(server);
+ addTestGlobal(title);
+ DevToolsServer.registerActors({ target: true });
+
+ const transport = DevToolsServer.connectPipe();
+ const client = new DevToolsClient(transport);
+
+ await connect(client);
+ return client;
+}
+
+async function finishClient(client) {
+ await client.close();
+ DevToolsServer.destroy();
+ do_test_finished();
+}
+
+/**
+ * Takes a relative file path and returns the absolute file url for it.
+ */
+function getFileUrl(name, allowMissing = false) {
+ const file = do_get_file(name, allowMissing);
+ return Services.io.newFileURI(file).spec;
+}
+
+/**
+ * Returns the full path of the file with the specified name in a
+ * platform-independent and URL-like form.
+ */
+function getFilePath(
+ name,
+ allowMissing = false,
+ usePlatformPathSeparator = false
+) {
+ const file = do_get_file(name, allowMissing);
+ let path = Services.io.newFileURI(file).spec;
+ let filePrePath = "file://";
+ if ("nsILocalFileWin" in Ci && file instanceof Ci.nsILocalFileWin) {
+ filePrePath += "/";
+ }
+
+ path = path.slice(filePrePath.length);
+
+ if (usePlatformPathSeparator && path.match(/^\w:/)) {
+ path = path.replace(/\//g, "\\");
+ }
+
+ return path;
+}
+
+/**
+ * Returns the full text contents of the given file.
+ */
+function readFile(fileName) {
+ const f = do_get_file(fileName);
+ const s = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ s.init(f, -1, -1, false);
+ try {
+ return NetUtil.readInputStreamToString(s, s.available());
+ } finally {
+ s.close();
+ }
+}
+
+function writeFile(fileName, content) {
+ const file = do_get_file(fileName, true);
+ const stream = Cc["@mozilla.org/network/file-output-stream;1"].createInstance(
+ Ci.nsIFileOutputStream
+ );
+ stream.init(file, -1, -1, 0);
+ try {
+ do {
+ const numWritten = stream.write(content, content.length);
+ content = content.slice(numWritten);
+ } while (content.length);
+ } finally {
+ stream.close();
+ }
+}
+
+function StubTransport() {}
+StubTransport.prototype.ready = function () {};
+StubTransport.prototype.send = function () {};
+StubTransport.prototype.close = function () {};
+
+// Create async version of the object where calling each method
+// is equivalent of calling it with asyncall. Mainly useful for
+// destructuring objects with methods that take callbacks.
+const Async = target => new Proxy(target, Async);
+Async.get = (target, name) =>
+ typeof target[name] === "function"
+ ? asyncall.bind(null, target[name], target)
+ : target[name];
+
+// Calls async function that takes callback and errorback and returns
+// returns promise representing result.
+const asyncall = (fn, self, ...args) =>
+ new Promise((...etc) => fn.call(self, ...args, ...etc));
+
+const Test = task => () => {
+ add_task(task);
+ run_next_test();
+};
+
+const assert = Assert.ok.bind(Assert);
+
+/**
+ * Create a promise that is resolved on the next occurence of the given event.
+ *
+ * @param ThreadFront threadFront
+ * @param String event
+ * @param Function predicate
+ * @returns Promise
+ */
+function waitForEvent(front, type, predicate) {
+ if (!predicate) {
+ return front.once(type);
+ }
+
+ return new Promise(function (resolve) {
+ function listener(packet) {
+ if (!predicate(packet)) {
+ return;
+ }
+ front.off(type, listener);
+ resolve(packet);
+ }
+ front.on(type, listener);
+ });
+}
+
+/**
+ * Execute the action on the next tick and return a promise that is resolved on
+ * the next pause.
+ *
+ * When using promises and Task.jsm, we often want to do an action that causes a
+ * pause and continue the task once the pause has ocurred. Unfortunately, if we
+ * do the action that causes the pause within the task's current tick we will
+ * pause before we have a chance to yield the promise that waits for the pause
+ * and we enter a dead lock. The solution is to create the promise that waits
+ * for the pause, schedule the action to run on the next tick of the event loop,
+ * and finally yield the promise.
+ *
+ * @param Function action
+ * @param ThreadFront threadFront
+ * @returns Promise
+ */
+function executeOnNextTickAndWaitForPause(action, threadFront) {
+ const paused = waitForPause(threadFront);
+ executeSoon(action);
+ return paused;
+}
+
+function evalCallback(debuggeeGlobal, func) {
+ Cu.evalInSandbox("(" + func + ")()", debuggeeGlobal, "1.8", "test.js", 1);
+}
+
+/**
+ * Interrupt JS execution for the specified thread.
+ *
+ * @param ThreadFront threadFront
+ * @returns Promise
+ */
+function interrupt(threadFront) {
+ dumpn("Interrupting.");
+ return threadFront.interrupt();
+}
+
+/**
+ * Resume JS execution for the specified thread and then wait for the next pause
+ * event.
+ *
+ * @param DevToolsClient client
+ * @param ThreadFront threadFront
+ * @returns Promise
+ */
+async function resumeAndWaitForPause(threadFront) {
+ const paused = waitForPause(threadFront);
+ await resume(threadFront);
+ return paused;
+}
+
+/**
+ * Resume JS execution for a single step and wait for the pause after the step
+ * has been taken.
+ *
+ * @param ThreadFront threadFront
+ * @returns Promise
+ */
+function stepIn(threadFront) {
+ dumpn("Stepping in.");
+ const paused = waitForPause(threadFront);
+ return threadFront.stepIn().then(() => paused);
+}
+
+/**
+ * Resume JS execution for a step over and wait for the pause after the step
+ * has been taken.
+ *
+ * @param ThreadFront threadFront
+ * @returns Promise
+ */
+async function stepOver(threadFront, frameActor) {
+ dumpn("Stepping over.");
+ await threadFront.stepOver(frameActor);
+ return waitForPause(threadFront);
+}
+
+/**
+ * Resume JS execution for a step out and wait for the pause after the step
+ * has been taken.
+ *
+ * @param DevToolsClient client
+ * @param ThreadFront threadFront
+ * @returns Promise
+ */
+async function stepOut(threadFront, frameActor) {
+ dumpn("Stepping out.");
+ await threadFront.stepOut(frameActor);
+ return waitForPause(threadFront);
+}
+
+/**
+ * Restart specific frame and wait for the pause after the restart
+ * has been taken.
+ *
+ * @param DevToolsClient client
+ * @param ThreadFront threadFront
+ * @returns Promise
+ */
+async function restartFrame(threadFront, frameActor) {
+ dumpn("Restarting frame.");
+ await threadFront.restart(frameActor);
+ return waitForPause(threadFront);
+}
+
+/**
+ * Get the list of `count` frames currently on stack, starting at the index
+ * `first` for the specified thread.
+ *
+ * @param ThreadFront threadFront
+ * @param Number first
+ * @param Number count
+ * @returns Promise
+ */
+function getFrames(threadFront, first, count) {
+ dumpn("Getting frames.");
+ return threadFront.getFrames(first, count);
+}
+
+/**
+ * Black box the specified source.
+ *
+ * @param SourceFront sourceFront
+ * @returns Promise
+ */
+async function blackBox(sourceFront, range = null) {
+ dumpn("Black boxing source: " + sourceFront.actor);
+ const pausedInSource = await sourceFront.blackBox(range);
+ ok(true, "blackBox didn't throw");
+ return pausedInSource;
+}
+
+/**
+ * Stop black boxing the specified source.
+ *
+ * @param SourceFront sourceFront
+ * @returns Promise
+ */
+async function unBlackBox(sourceFront, range = null) {
+ dumpn("Un-black boxing source: " + sourceFront.actor);
+ await sourceFront.unblackBox(range);
+ ok(true, "unblackBox didn't throw");
+}
+
+/**
+ * Get a source at the specified url.
+ *
+ * @param ThreadFront threadFront
+ * @param string url
+ * @returns Promise<SourceFront>
+ */
+async function getSource(threadFront, url) {
+ const source = await getSourceForm(threadFront, url);
+ if (source) {
+ return threadFront.source(source);
+ }
+
+ throw new Error("source not found");
+}
+
+async function getSourceById(threadFront, id) {
+ const form = await getSourceFormById(threadFront, id);
+ return threadFront.source(form);
+}
+
+async function getSourceForm(threadFront, url) {
+ const { sources } = await threadFront.getSources();
+ return sources.find(s => s.url === url);
+}
+
+async function getSourceFormById(threadFront, id) {
+ const { sources } = await threadFront.getSources();
+ return sources.find(source => source.actor == id);
+}
+
+async function checkFramesLength(threadFront, expectedFrames) {
+ const frameResponse = await threadFront.getFrames(0, null);
+ Assert.equal(
+ frameResponse.frames.length,
+ expectedFrames,
+ "Thread front has the expected number of frames"
+ );
+}
+
+/**
+ * Do a reload which clears the thread debugger
+ *
+ * @param TabFront tabFront
+ * @returns Promise<response>
+ */
+function reload(tabFront) {
+ return tabFront.reload({});
+}
+
+/**
+ * Returns an array of stack location strings given a thread and a sample.
+ *
+ * @param object thread
+ * @param object sample
+ * @returns object
+ */
+function getInflatedStackLocations(thread, sample) {
+ const stackTable = thread.stackTable;
+ const frameTable = thread.frameTable;
+ const stringTable = thread.stringTable;
+ const SAMPLE_STACK_SLOT = thread.samples.schema.stack;
+ const STACK_PREFIX_SLOT = stackTable.schema.prefix;
+ const STACK_FRAME_SLOT = stackTable.schema.frame;
+ const FRAME_LOCATION_SLOT = frameTable.schema.location;
+
+ // Build the stack from the raw data and accumulate the locations in
+ // an array.
+ let stackIndex = sample[SAMPLE_STACK_SLOT];
+ const locations = [];
+ while (stackIndex !== null) {
+ const stackEntry = stackTable.data[stackIndex];
+ const frame = frameTable.data[stackEntry[STACK_FRAME_SLOT]];
+ locations.push(stringTable[frame[FRAME_LOCATION_SLOT]]);
+ stackIndex = stackEntry[STACK_PREFIX_SLOT];
+ }
+
+ // The profiler tree is inverted, so reverse the array.
+ return locations.reverse();
+}
+
+async function setupTestFromUrl(url) {
+ do_test_pending();
+
+ const { createRootActor } = require("xpcshell-test/testactors");
+ DevToolsServer.setRootActor(createRootActor);
+ DevToolsServer.init(() => true);
+
+ const global = createTestGlobal("test");
+ DevToolsServer.addTestGlobal(global);
+
+ const devToolsClient = new DevToolsClient(DevToolsServer.connectPipe());
+ await connect(devToolsClient);
+
+ const tabs = await listTabs(devToolsClient);
+ const descriptorFront = findTab(tabs, "test");
+
+ // These xpcshell tests use mocked actors (xpcshell-test/testactors)
+ // which still don't support watcher actor.
+ // Because of that we still can't enable server side targets and target swiching.
+ descriptorFront.disableTargetSwitching();
+
+ const targetFront = await descriptorFront.getTarget();
+
+ const threadFront = await attachThread(targetFront);
+
+ const sourceUrl = getFileUrl(url);
+ const promise = waitForNewSource(threadFront, sourceUrl);
+ loadSubScript(sourceUrl, global);
+ const { source } = await promise;
+
+ const sourceFront = threadFront.source(source);
+ return { global, devToolsClient, threadFront, sourceFront };
+}
+
+/**
+ * Run the given test function twice, one with a regular DevToolsServer,
+ * testing against a fake tab. And another one against a WorkerDevToolsServer,
+ * testing the worker codepath.
+ *
+ * @param Function test
+ * Test function to run twice.
+ * This test function is called with a dictionary:
+ * - Sandbox debuggee
+ * The custom JS debuggee created for this test. This is a Sandbox using system
+ * principals by default.
+ * - ThreadFront threadFront
+ * A reference to a ThreadFront instance that is attached to the debuggee.
+ * - DevToolsClient client
+ * A reference to the DevToolsClient used to communicated with the RDP server.
+ * @param Object options
+ * Optional arguments to tweak test environment
+ * - JSPrincipal principal
+ * Principal to use for the debuggee. Defaults to systemPrincipal.
+ * - boolean doNotRunWorker
+ * If true, do not run this tests in worker debugger context. Defaults to false.
+ * - bool wantXrays
+ * Whether the debuggee wants Xray vision with respect to same-origin objects
+ * outside the sandbox. Defaults to true.
+ * - bool waitForFinish
+ * Whether to wait for a call to threadFrontTestFinished after the test
+ * function finishes.
+ */
+function threadFrontTest(test, options = {}) {
+ const {
+ principal = systemPrincipal,
+ doNotRunWorker = false,
+ wantXrays = true,
+ waitForFinish = false,
+ } = options;
+
+ async function runThreadFrontTestWithServer(server, test) {
+ // Setup a server and connect a client to it.
+ initTestDevToolsServer(server);
+
+ // Create a custom debuggee and register it to the server.
+ // We are using a custom Sandbox as debuggee. Create a new zone because
+ // debugger and debuggee must be in different compartments.
+ const debuggee = Cu.Sandbox(principal, { freshZone: true, wantXrays });
+ const scriptName = "debuggee.js";
+ debuggee.__name = scriptName;
+ server.addTestGlobal(debuggee);
+
+ const client = new DevToolsClient(server.connectPipe());
+ await client.connect();
+
+ // Attach to the fake tab target and retrieve the ThreadFront instance.
+ // Automatically resume as the thread is paused by default after attach.
+ const { targetFront, threadFront, commands } = await attachTestThread(
+ client,
+ scriptName
+ );
+
+ // Cross the client/server boundary to retrieve the target actor & thread
+ // actor instances, used by some tests.
+ const rootActor = client.transport._serverConnection.rootActor;
+ const targetActor =
+ rootActor._parameters.tabList.getTargetActorForTab("debuggee.js");
+ const { threadActor } = targetActor;
+
+ // Run the test function
+ const args = {
+ threadActor,
+ threadFront,
+ debuggee,
+ client,
+ server,
+ targetFront,
+ commands,
+ isWorkerServer: server === WorkerDevToolsServer,
+ };
+ if (waitForFinish) {
+ // Use dispatchToMainThread so that the test function does not have to
+ // finish executing before the test itself finishes.
+ const promise = new Promise(
+ resolve => (threadFrontTestFinished = resolve)
+ );
+ Services.tm.dispatchToMainThread(() => test(args));
+ await promise;
+ } else {
+ await test(args);
+ }
+
+ // Cleanup the client after the test ran
+ await client.close();
+
+ server.removeTestGlobal(debuggee);
+
+ // Also cleanup the created server
+ server.destroy();
+ }
+
+ return async () => {
+ dump(">>> Run thread front test against a regular DevToolsServer\n");
+ await runThreadFrontTestWithServer(DevToolsServer, test);
+
+ // Skip tests that fail in the worker context
+ if (!doNotRunWorker) {
+ dump(">>> Run thread front test against a worker DevToolsServer\n");
+ await runThreadFrontTestWithServer(WorkerDevToolsServer, test);
+ }
+ };
+}
+
+// This callback is used in tandem with the waitForFinish option of
+// threadFrontTest to support thread front tests that use promises to
+// asynchronously finish the tests, instead of using async/await.
+// Newly written tests should avoid using this. See bug 1596114 for migrating
+// existing tests to async/await and removing this functionality.
+let threadFrontTestFinished;
diff --git a/devtools/server/tests/xpcshell/hello-actor.js b/devtools/server/tests/xpcshell/hello-actor.js
new file mode 100644
index 0000000000..f4fc63cb86
--- /dev/null
+++ b/devtools/server/tests/xpcshell/hello-actor.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint no-unused-vars: ["error", {"vars": "local"}] */
+
+"use strict";
+
+const protocol = require("resource://devtools/shared/protocol.js");
+
+const helloSpec = protocol.generateActorSpec({
+ typeName: "helloActor",
+
+ methods: {
+ hello: {},
+ },
+});
+
+class HelloActor extends protocol.Actor {
+ constructor(conn) {
+ super(conn, helloSpec);
+ }
+
+ hello() {}
+}
diff --git a/devtools/server/tests/xpcshell/post_init_global_actors.js b/devtools/server/tests/xpcshell/post_init_global_actors.js
new file mode 100644
index 0000000000..4ec5fb8078
--- /dev/null
+++ b/devtools/server/tests/xpcshell/post_init_global_actors.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+
+class PostInitGlobalActor extends Actor {
+ constructor(conn) {
+ super(conn, { typeName: "postInitGlobal", methods: [] });
+
+ this.requestTypes = {
+ ping: this.onPing,
+ };
+ }
+
+ onPing() {
+ return { message: "pong" };
+ }
+}
+
+exports.PostInitGlobalActor = PostInitGlobalActor;
diff --git a/devtools/server/tests/xpcshell/post_init_target_scoped_actors.js b/devtools/server/tests/xpcshell/post_init_target_scoped_actors.js
new file mode 100644
index 0000000000..9b0b4c053e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/post_init_target_scoped_actors.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+
+class PostInitTargetScopedActor extends Actor {
+ constructor(conn) {
+ super(conn, { typeName: "postInitTargetScoped", methods: [] });
+
+ this.requestTypes = {
+ ping: this.onPing,
+ };
+ }
+
+ onPing() {
+ return { message: "pong" };
+ }
+}
+
+exports.PostInitTargetScopedActor = PostInitTargetScopedActor;
diff --git a/devtools/server/tests/xpcshell/pre_init_global_actors.js b/devtools/server/tests/xpcshell/pre_init_global_actors.js
new file mode 100644
index 0000000000..f5e14aaaa9
--- /dev/null
+++ b/devtools/server/tests/xpcshell/pre_init_global_actors.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+
+class PreInitGlobalActor extends Actor {
+ constructor(conn) {
+ super(conn, { typeName: "preInitGlobal", methods: [] });
+
+ this.requestTypes = {
+ ping: this.onPing,
+ };
+ }
+
+ onPing() {
+ return { message: "pong" };
+ }
+}
+
+exports.PreInitGlobalActor = PreInitGlobalActor;
diff --git a/devtools/server/tests/xpcshell/pre_init_target_scoped_actors.js b/devtools/server/tests/xpcshell/pre_init_target_scoped_actors.js
new file mode 100644
index 0000000000..360d4b52a0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/pre_init_target_scoped_actors.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+
+class PreInitTargetScopedActor extends Actor {
+ constructor(conn) {
+ super(conn, { typeName: "preInitTargetScoped", methods: [] });
+
+ this.requestTypes = {
+ ping: this.onPing,
+ };
+ }
+
+ onPing() {
+ return { message: "pong" };
+ }
+}
+
+exports.PreInitTargetScopedActor = PreInitTargetScopedActor;
diff --git a/devtools/server/tests/xpcshell/registertestactors-lazy.js b/devtools/server/tests/xpcshell/registertestactors-lazy.js
new file mode 100644
index 0000000000..ef04e7a8d2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/registertestactors-lazy.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var {
+ RetVal,
+ Actor,
+ FrontClassWithSpec,
+ generateActorSpec,
+} = require("resource://devtools/shared/protocol.js");
+
+const lazySpec = generateActorSpec({
+ typeName: "lazy",
+
+ methods: {
+ hello: {
+ response: { str: RetVal("string") },
+ },
+ },
+});
+
+class LazyActor extends Actor {
+ constructor(conn, id) {
+ super(conn, lazySpec);
+
+ Services.obs.notifyObservers(null, "actor", "instantiated");
+ }
+
+ hello(str) {
+ return "world";
+ }
+}
+exports.LazyActor = LazyActor;
+
+Services.obs.notifyObservers(null, "actor", "loaded");
+
+class LazyFront extends FrontClassWithSpec(lazySpec) {
+ constructor(client) {
+ super(client);
+ }
+}
+exports.LazyFront = LazyFront;
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-column-in-gcd-script.js b/devtools/server/tests/xpcshell/setBreakpoint-on-column-in-gcd-script.js
new file mode 100644
index 0000000000..575915c4fd
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-column-in-gcd-script.js
@@ -0,0 +1,7 @@
+"use strict";
+
+function f() {}
+
+(function () {
+ var a = 1; var b = 2; var c = 3;
+})();
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-column-minified.js b/devtools/server/tests/xpcshell/setBreakpoint-on-column-minified.js
new file mode 100644
index 0000000000..1fbf8ef16e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-column-minified.js
@@ -0,0 +1,8 @@
+"use strict";
+
+function other(){ var a = 1; } function test(){ var a = 1; var b = 2; var c = 3; }
+
+function f() {
+ other();
+ test();
+} \ No newline at end of file
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets-in-gcd-script.js b/devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets-in-gcd-script.js
new file mode 100644
index 0000000000..adce39193d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets-in-gcd-script.js
@@ -0,0 +1,7 @@
+"use strict";
+
+function f() {}
+
+(function () {
+ var a = 1; var c = 3;
+})();
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets.js b/devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets.js
new file mode 100644
index 0000000000..5faefc3c88
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-column-with-no-offsets.js
@@ -0,0 +1,5 @@
+"use strict";
+
+function f() {
+ var a = 1; var c = 3;
+}
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-column.js b/devtools/server/tests/xpcshell/setBreakpoint-on-column.js
new file mode 100644
index 0000000000..d92231e651
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-column.js
@@ -0,0 +1,5 @@
+"use strict";
+
+function f() {
+ var a = 1; var b = 2; var c = 3;
+}
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-line-in-gcd-script.js b/devtools/server/tests/xpcshell/setBreakpoint-on-line-in-gcd-script.js
new file mode 100644
index 0000000000..fb96be8aba
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-line-in-gcd-script.js
@@ -0,0 +1,9 @@
+"use strict";
+
+function f() {}
+
+(function () {
+ var a = 1;
+ var b = 2;
+ var c = 3;
+})();
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-offsets.js b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-offsets.js
new file mode 100644
index 0000000000..b30ebb5049
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-offsets.js
@@ -0,0 +1,7 @@
+"use strict";
+
+function f() {
+ for (var i = 0; i < 1; ++i) {
+ ;
+ }
+}
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-statements.js b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-statements.js
new file mode 100644
index 0000000000..d92231e651
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-multiple-statements.js
@@ -0,0 +1,5 @@
+"use strict";
+
+function f() {
+ var a = 1; var b = 2; var c = 3;
+}
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets-in-gcd-script.js b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets-in-gcd-script.js
new file mode 100644
index 0000000000..b03d400794
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets-in-gcd-script.js
@@ -0,0 +1,9 @@
+"use strict";
+
+function f() {}
+
+(function () {
+ var a = 1;
+
+ var c = 3;
+})();
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets.js b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets.js
new file mode 100644
index 0000000000..1268cf8db0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-line-with-no-offsets.js
@@ -0,0 +1,7 @@
+"use strict";
+
+function f() {
+ var a = 1;
+
+ var c = 3;
+}
diff --git a/devtools/server/tests/xpcshell/setBreakpoint-on-line.js b/devtools/server/tests/xpcshell/setBreakpoint-on-line.js
new file mode 100644
index 0000000000..1b15e2a5e7
--- /dev/null
+++ b/devtools/server/tests/xpcshell/setBreakpoint-on-line.js
@@ -0,0 +1,7 @@
+"use strict";
+
+function f() {
+ var a = 1;
+ var b = 2;
+ var c = 3;
+}
diff --git a/devtools/server/tests/xpcshell/source-03.js b/devtools/server/tests/xpcshell/source-03.js
new file mode 100644
index 0000000000..af623a2eb2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/source-03.js
@@ -0,0 +1,7 @@
+/* eslint-disable */
+
+function init() {
+ var a = foo();
+}
+
+function foo() {}
diff --git a/devtools/server/tests/xpcshell/source-map-data/sourcemapped.coffee b/devtools/server/tests/xpcshell/source-map-data/sourcemapped.coffee
new file mode 100644
index 0000000000..73a400a219
--- /dev/null
+++ b/devtools/server/tests/xpcshell/source-map-data/sourcemapped.coffee
@@ -0,0 +1,6 @@
+foo = (n) ->
+ return "foo" + i for i in [0...n]
+
+[first, second, third] = foo(3)
+
+debugger \ No newline at end of file
diff --git a/devtools/server/tests/xpcshell/source-map-data/sourcemapped.map b/devtools/server/tests/xpcshell/source-map-data/sourcemapped.map
new file mode 100644
index 0000000000..dcee3c33c3
--- /dev/null
+++ b/devtools/server/tests/xpcshell/source-map-data/sourcemapped.map
@@ -0,0 +1,10 @@
+{
+ "version": 3,
+ "file": "sourcemapped.js",
+ "sourceRoot": "",
+ "sources": [
+ "sourcemapped.coffee"
+ ],
+ "names": [],
+ "mappings": ";AAAA;CAAA,KAAA,yBAAA;CAAA;CAAA,CAAA,CAAA,MAAO;CACL,IAAA,GAAA;AAAA,CAAA,EAAA,MAA0B,qDAA1B;CAAA,EAAe,EAAR,QAAA;CAAP,IADI;CAAN,EAAM;;CAAN,CAGA,CAAyB,IAAA;;CAEzB,UALA;CAAA"
+} \ No newline at end of file
diff --git a/devtools/server/tests/xpcshell/sourcemapped.js b/devtools/server/tests/xpcshell/sourcemapped.js
new file mode 100644
index 0000000000..94d130903b
--- /dev/null
+++ b/devtools/server/tests/xpcshell/sourcemapped.js
@@ -0,0 +1,16 @@
+// Generated by CoffeeScript 1.6.1
+(function () {
+ var first, foo, second, third, _ref;
+
+ foo = function (n) {
+ var i, _i;
+ for (i = _i = 0; 0 <= n ? _i < n : _i > n; i = 0 <= n ? ++_i : --_i) {
+ return "foo" + i;
+ }
+ };
+
+ _ref = foo(3), first = _ref[0], second = _ref[1], third = _ref[2];
+
+ debugger;
+
+}).call(this);
diff --git a/devtools/server/tests/xpcshell/stepping-async.js b/devtools/server/tests/xpcshell/stepping-async.js
new file mode 100644
index 0000000000..0ee37883bc
--- /dev/null
+++ b/devtools/server/tests/xpcshell/stepping-async.js
@@ -0,0 +1,31 @@
+"use strict";
+/* exported stuff */
+
+async function timer() {
+ return Promise.resolve();
+}
+
+function oops() {
+ return `oops`;
+}
+
+async function inner() {
+ oops();
+ await timer();
+ Promise.resolve().then(async () => {
+ Promise.resolve().then(() => {
+ oops();
+ });
+ oops();
+ });
+ oops();
+}
+
+async function stuff() {
+ debugger;
+ const task = inner();
+ oops();
+ await task;
+ oops();
+ debugger;
+}
diff --git a/devtools/server/tests/xpcshell/stepping.js b/devtools/server/tests/xpcshell/stepping.js
new file mode 100644
index 0000000000..2134bea38d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/stepping.js
@@ -0,0 +1,36 @@
+"use strict";
+/* exported global arithmetic composition chaining nested */
+
+const obj = { b };
+
+function a() {
+ return obj;
+}
+
+function b() {
+ return 2;
+}
+
+function arithmetic() {
+ debugger;
+ a() + b();
+}
+
+function composition() {
+ debugger;
+ b(a());
+}
+
+function chaining() {
+ debugger;
+ a().b();
+}
+
+function c() {
+ return b();
+}
+
+function nested() {
+ debugger;
+ c();
+}
diff --git a/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_01.js b/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_01.js
new file mode 100644
index 0000000000..7df3cbd2ba
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_01.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that we can tell the memory actor to take a heap snapshot over the RDP
+// and then create a HeapSnapshot instance from the resulting file.
+
+add_task(async () => {
+ const { memoryFront } = await createTabMemoryFront();
+
+ const snapshotFilePath = await memoryFront.saveHeapSnapshot();
+ ok(
+ !!(await IOUtils.stat(snapshotFilePath)),
+ "Should have the heap snapshot file"
+ );
+ const snapshot = ChromeUtils.readHeapSnapshot(snapshotFilePath);
+ ok(
+ HeapSnapshot.isInstance(snapshot),
+ "And we should be able to read a HeapSnapshot instance from the file"
+ );
+});
diff --git a/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_02.js b/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_02.js
new file mode 100644
index 0000000000..91593d845f
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_02.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that we can properly stream heap snapshot files over the RDP as bulk
+// data.
+
+add_task(async () => {
+ const { memoryFront } = await createTabMemoryFront();
+
+ const snapshotFilePath = await memoryFront.saveHeapSnapshot({
+ forceCopy: true,
+ });
+ ok(
+ !!(await IOUtils.stat(snapshotFilePath)),
+ "Should have the heap snapshot file"
+ );
+ const snapshot = ChromeUtils.readHeapSnapshot(snapshotFilePath);
+ ok(
+ HeapSnapshot.isInstance(snapshot),
+ "And we should be able to read a HeapSnapshot instance from the file"
+ );
+});
diff --git a/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_03.js b/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_03.js
new file mode 100644
index 0000000000..b212abbced
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_MemoryActor_saveHeapSnapshot_03.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that we can save full runtime heap snapshots when attached to the
+// ParentProcessTargetActor or a ContentProcessTargetActor.
+
+add_task(async () => {
+ const { memoryFront } = await createMainProcessMemoryFront();
+
+ const snapshotFilePath = await memoryFront.saveHeapSnapshot();
+ ok(
+ !!(await IOUtils.stat(snapshotFilePath)),
+ "Should have the heap snapshot file"
+ );
+ const snapshot = ChromeUtils.readHeapSnapshot(snapshotFilePath);
+ ok(
+ HeapSnapshot.isInstance(snapshot),
+ "And we should be able to read a HeapSnapshot instance from the file"
+ );
+});
diff --git a/devtools/server/tests/xpcshell/test_add_actors.js b/devtools/server/tests/xpcshell/test_add_actors.js
new file mode 100644
index 0000000000..8077109d71
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_add_actors.js
@@ -0,0 +1,107 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Get the object, from the server side, for a given actor ID
+function getActorInstance(connID, actorID) {
+ return DevToolsServer._connections[connID].getActor(actorID);
+}
+
+/**
+ * The purpose of these tests is to verify that it's possible to add actors
+ * both before and after the DevToolsServer has been initialized, so addons
+ * that add actors don't have to poll the object for its initialization state
+ * in order to add actors after initialization but rather can add actors anytime
+ * regardless of the object's state.
+ */
+add_task(async function () {
+ ActorRegistry.registerModule("resource://test/pre_init_global_actors.js", {
+ prefix: "preInitGlobal",
+ constructor: "PreInitGlobalActor",
+ type: { global: true },
+ });
+ ActorRegistry.registerModule(
+ "resource://test/pre_init_target_scoped_actors.js",
+ {
+ prefix: "preInitTargetScoped",
+ constructor: "PreInitTargetScopedActor",
+ type: { target: true },
+ }
+ );
+
+ const client = await startTestDevToolsServer("example tab");
+
+ ActorRegistry.registerModule("resource://test/post_init_global_actors.js", {
+ prefix: "postInitGlobal",
+ constructor: "PostInitGlobalActor",
+ type: { global: true },
+ });
+ ActorRegistry.registerModule(
+ "resource://test/post_init_target_scoped_actors.js",
+ {
+ prefix: "postInitTargetScoped",
+ constructor: "PostInitTargetScopedActor",
+ type: { target: true },
+ }
+ );
+
+ let actors = await client.mainRoot.rootForm;
+ const tabs = await client.mainRoot.listTabs();
+ const tabDescriptor = tabs[0];
+
+ // These xpcshell tests use mocked actors (xpcshell-test/testactors)
+ // which still don't support watcher actor.
+ // Because of that we still can't enable server side targets and target swiching.
+ tabDescriptor.disableTargetSwitching();
+
+ const tabTarget = await tabDescriptor.getTarget();
+
+ Assert.equal(tabs.length, 1);
+
+ let reply = await client.request({
+ to: actors.preInitGlobalActor,
+ type: "ping",
+ });
+ Assert.equal(reply.message, "pong");
+
+ reply = await client.request({
+ to: tabTarget.targetForm.preInitTargetScopedActor,
+ type: "ping",
+ });
+ Assert.equal(reply.message, "pong");
+
+ reply = await client.request({
+ to: actors.postInitGlobalActor,
+ type: "ping",
+ });
+ Assert.equal(reply.message, "pong");
+
+ reply = await client.request({
+ to: tabTarget.targetForm.postInitTargetScopedActor,
+ type: "ping",
+ });
+ Assert.equal(reply.message, "pong");
+
+ // Consider that there is only one connection, and the first one is ours
+ const connID = Object.keys(DevToolsServer._connections)[0];
+ const postInitGlobalActor = getActorInstance(
+ connID,
+ actors.postInitGlobalActor
+ );
+ const preInitGlobalActor = getActorInstance(
+ connID,
+ actors.preInitGlobalActor
+ );
+ actors = await client.mainRoot.getRoot();
+ Assert.equal(
+ postInitGlobalActor,
+ getActorInstance(connID, actors.postInitGlobalActor)
+ );
+ Assert.equal(
+ preInitGlobalActor,
+ getActorInstance(connID, actors.preInitGlobalActor)
+ );
+
+ await client.close();
+});
diff --git a/devtools/server/tests/xpcshell/test_addon_debugging_connect.js b/devtools/server/tests/xpcshell/test_addon_debugging_connect.js
new file mode 100644
index 0000000000..221e73d256
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_addon_debugging_connect.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+const { createAppInfo, promiseStartupManager } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+ExtensionTestUtils.init(this);
+
+function watchFrameUpdates(front) {
+ const collected = [];
+
+ const listener = data => {
+ collected.push(data);
+ };
+
+ front.on("frameUpdate", listener);
+ let unsubscribe = () => {
+ unsubscribe = null;
+ front.off("frameUpdate", listener);
+ return collected;
+ };
+
+ return unsubscribe;
+}
+
+function promiseFrameUpdate(front, matcher = () => true) {
+ return new Promise(resolve => {
+ const listener = data => {
+ if (matcher(data)) {
+ resolve();
+ front.off("frameUpdate", listener);
+ }
+ };
+
+ front.on("frameUpdate", listener);
+ });
+}
+
+// Bug 1302702 - Test connect to a webextension addon
+add_task(
+ {
+ // This test needs to run only when the extension are running in a separate
+ // child process, otherwise attachThread would pause the main process and this
+ // test would get stuck.
+ skip_if: () => !WebExtensionPolicy.useRemoteWebExtensions,
+ },
+ async function test_webextension_addon_debugging_connect() {
+ await promiseStartupManager();
+
+ // Install and start a test webextension.
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+ background() {
+ const { browser } = this;
+ browser.test.log("background script executed");
+ // window is available in background scripts
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage("background page ready", window.location.href);
+ },
+ });
+ await extension.startup();
+ const bgPageURL = await extension.awaitMessage("background page ready");
+
+ const commands = await CommandsFactory.forAddon(extension.id);
+
+ // Connect to the target addon actor and wait for the updated list of frames.
+ const addonTarget = await commands.descriptorFront.getTarget();
+ ok(addonTarget, "Got an RDP target");
+
+ const { frames } = await addonTarget.listFrames();
+ const backgroundPageFrame = frames
+ .filter(frame => {
+ return (
+ frame.url && frame.url.endsWith("/_generated_background_page.html")
+ );
+ })
+ .pop();
+ ok(backgroundPageFrame, "Found the frame for the background page");
+
+ const threadFront = await addonTarget.attachThread();
+
+ ok(threadFront, "Got a threadFront for the target addon");
+ equal(threadFront.paused, false, "The addon threadActor isn't paused");
+
+ equal(
+ lazy.ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "The expected number of debug browser has been created by the addon actor"
+ );
+
+ const unwatchFrameUpdates = watchFrameUpdates(addonTarget);
+
+ const promiseBgPageFrameUpdate = promiseFrameUpdate(addonTarget, data => {
+ return data.frames?.some(frame => frame.url === bgPageURL);
+ });
+
+ // Reload the addon through the RDP protocol.
+ await addonTarget.reload();
+ info("Wait background page to be fully reloaded");
+ await extension.awaitMessage("background page ready");
+ info("Wait background page frameUpdate event");
+ await promiseBgPageFrameUpdate;
+
+ equal(
+ lazy.ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 1,
+ "The number of debug browser has not been changed after an addon reload"
+ );
+
+ const frameUpdates = unwatchFrameUpdates();
+ const [frameUpdate] = frameUpdates;
+
+ equal(
+ frameUpdates.length,
+ 1,
+ "Expect 1 frameUpdate events to have been received"
+ );
+ equal(
+ frameUpdate.frames?.length,
+ 1,
+ "Expect 1 frame in the frameUpdate event "
+ );
+ Assert.deepEqual(
+ {
+ url: frameUpdate.frames[0].url,
+ },
+ {
+ url: bgPageURL,
+ },
+ "Got the expected frame update when the addon background page was loaded back"
+ );
+
+ await commands.destroy();
+
+ // Check that if we close the debugging client without uninstalling the addon,
+ // the webextension debugging actor should release the debug browser.
+ equal(
+ lazy.ExtensionParent.DebugUtils.debugBrowserPromises.size,
+ 0,
+ "The debug browser has been released when the RDP connection has been closed"
+ );
+
+ await extension.unload();
+ }
+);
diff --git a/devtools/server/tests/xpcshell/test_addon_events.js b/devtools/server/tests/xpcshell/test_addon_events.js
new file mode 100644
index 0000000000..262a604953
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_addon_events.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+add_task(async function testReloadExitedAddon() {
+ await startupAddonsManager();
+
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ await client.connect();
+
+ // Retrieve the current list of addons to be notified of the next list update.
+ // We will also call listAddons every time we receive the event "addonListChanged" for
+ // the same reason.
+ await client.mainRoot.listAddons();
+
+ info("Install the addon");
+ const addonFile = do_get_file("addons/web-extension", false);
+
+ let installedAddon;
+ await expectAddonListChanged(client, async () => {
+ installedAddon = await AddonManager.installTemporaryAddon(addonFile);
+ });
+ ok(true, "Received onAddonListChanged when installing addon");
+
+ info("Disable the addon");
+ await expectAddonListChanged(client, () => installedAddon.disable());
+ ok(true, "Received onAddonListChanged when disabling addon");
+
+ info("Enable the addon");
+ await expectAddonListChanged(client, () => installedAddon.enable());
+ ok(true, "Received onAddonListChanged when enabling addon");
+
+ info("Put the addon in pending uninstall mode");
+ await expectAddonListChanged(client, () => installedAddon.uninstall(true));
+ ok(true, "Received onAddonListChanged when addon moves to pending uninstall");
+
+ info("Cancel uninstall for addon");
+ await expectAddonListChanged(client, () => installedAddon.cancelUninstall());
+ ok(true, "Received onAddonListChanged when addon uninstall is canceled");
+
+ info("Completely uninstall the addon");
+ await expectAddonListChanged(client, () => installedAddon.uninstall());
+ ok(true, "Received onAddonListChanged when addon is uninstalled");
+
+ await close(client);
+});
+
+async function expectAddonListChanged(client, predicate) {
+ const onAddonListChanged = client.mainRoot.once("addonListChanged");
+ await predicate();
+ await onAddonListChanged;
+ await client.mainRoot.listAddons();
+}
diff --git a/devtools/server/tests/xpcshell/test_addon_reload.js b/devtools/server/tests/xpcshell/test_addon_reload.js
new file mode 100644
index 0000000000..e0054f03cc
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_addon_reload.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+function promiseAddonEvent(event) {
+ return new Promise(resolve => {
+ const listener = {
+ [event](...args) {
+ AddonManager.removeAddonListener(listener);
+ resolve(args);
+ },
+ };
+
+ AddonManager.addAddonListener(listener);
+ });
+}
+
+function promiseWebExtensionStartup() {
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+
+ return new Promise(resolve => {
+ const listener = (evt, extension) => {
+ Management.off("ready", listener);
+ resolve(extension);
+ };
+
+ Management.on("ready", listener);
+ });
+}
+
+async function reloadAddon(addonFront) {
+ // The add-on will be re-installed after a successful reload.
+ const onInstalled = promiseAddonEvent("onInstalled");
+ await addonFront.reload();
+ await onInstalled;
+}
+
+function getSupportFile(path) {
+ const allowMissing = false;
+ return do_get_file(path, allowMissing);
+}
+
+add_task(async function testReloadExitedAddon() {
+ await startupAddonsManager();
+
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ await client.connect();
+
+ // Install our main add-on to trigger reloads on.
+ const addonFile = getSupportFile("addons/web-extension");
+ const [installedAddon] = await Promise.all([
+ AddonManager.installTemporaryAddon(addonFile),
+ promiseWebExtensionStartup(),
+ ]);
+
+ // Install a decoy add-on.
+ const addonFile2 = getSupportFile("addons/web-extension2");
+ const [installedAddon2] = await Promise.all([
+ AddonManager.installTemporaryAddon(addonFile2),
+ promiseWebExtensionStartup(),
+ ]);
+
+ const addonFront = await client.mainRoot.getAddon({ id: installedAddon.id });
+
+ await Promise.all([reloadAddon(addonFront), promiseWebExtensionStartup()]);
+
+ // Uninstall the decoy add-on, which should cause its actor to exit.
+ const onUninstalled = promiseAddonEvent("onUninstalled");
+ installedAddon2.uninstall();
+ await onUninstalled;
+
+ // Try to re-list all add-ons after a reload.
+ // This was throwing an exception because of the exited actor.
+ const newAddonFront = await client.mainRoot.getAddon({
+ id: installedAddon.id,
+ });
+ equal(newAddonFront.id, addonFront.id);
+
+ // The fronts should be the same after the reload
+ equal(newAddonFront, addonFront);
+
+ const onAddonListChanged = client.mainRoot.once("addonListChanged");
+
+ // Install an upgrade version of the first add-on.
+ const addonUpgradeFile = getSupportFile("addons/web-extension-upgrade");
+ const [upgradedAddon] = await Promise.all([
+ AddonManager.installTemporaryAddon(addonUpgradeFile),
+ promiseWebExtensionStartup(),
+ ]);
+
+ // Waiting for addonListChanged unsolicited event
+ await onAddonListChanged;
+
+ // re-list all add-ons after an upgrade.
+ const upgradedAddonFront = await client.mainRoot.getAddon({
+ id: upgradedAddon.id,
+ });
+ equal(upgradedAddonFront.id, addonFront.id);
+ // The fronts should be the same after the upgrade.
+ equal(upgradedAddonFront, addonFront);
+
+ // The addon metadata has been updated.
+ equal(upgradedAddonFront.name, "Test Addons Actor Upgrade");
+
+ await close(client);
+});
diff --git a/devtools/server/tests/xpcshell/test_addons_actor.js b/devtools/server/tests/xpcshell/test_addons_actor.js
new file mode 100644
index 0000000000..ba9fda6c3d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_addons_actor.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function connect() {
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ await client.connect();
+
+ const addons = await client.mainRoot.getFront("addons");
+ return [client, addons];
+}
+
+// The AddonsManager test helper can only be called once per test script.
+// This `setup` task will run first.
+add_task(async function setup() {
+ await startupAddonsManager();
+});
+
+add_task(async function testSuccessfulInstall() {
+ const [client, addons] = await connect();
+
+ const allowMissing = false;
+ const usePlatformSeparator = true;
+ const addonPath = getFilePath(
+ "addons/web-extension",
+ allowMissing,
+ usePlatformSeparator
+ );
+ const installedAddon = await addons.installTemporaryAddon(addonPath, false);
+ equal(installedAddon.id, "test-addons-actor@mozilla.org");
+ // The returned object is currently not a proper actor.
+ equal(installedAddon.actor, false);
+
+ const addonList = await client.mainRoot.listAddons();
+ ok(addonList && addonList.map(a => a.name), "Received list of add-ons");
+ const addon = addonList.find(a => a.id === installedAddon.id);
+ ok(addon, "Test add-on appeared in root install list");
+
+ await close(client);
+});
+
+add_task(async function testNonExistantPath() {
+ const [client, addons] = await connect();
+
+ await Assert.rejects(
+ addons.installTemporaryAddon("some-non-existant-path", false),
+ /Could not install add-on.*Component returned failure/
+ );
+
+ await close(client);
+});
diff --git a/devtools/server/tests/xpcshell/test_animation_name.js b/devtools/server/tests/xpcshell/test_animation_name.js
new file mode 100644
index 0000000000..e88911334c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_animation_name.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that AnimationPlayerActor.getName returns the right name depending on
+// the type of an animation and the various properties available on it.
+
+const {
+ AnimationPlayerActor,
+} = require("resource://devtools/server/actors/animation.js");
+
+function run_test() {
+ // Mock a window with just the properties the AnimationPlayerActor uses.
+ const window = {};
+ window.MutationObserver = class {
+ constructor() {
+ this.observe = () => {};
+ }
+ };
+ window.Animation = class {
+ constructor() {
+ this.effect = { target: getMockNode() };
+ }
+ };
+
+ window.CSSAnimation = class extends window.Animation {};
+ window.CSSTransition = class extends window.Animation {};
+
+ // Helper to get a mock DOM node.
+ function getMockNode() {
+ return {
+ ownerDocument: {
+ defaultView: window,
+ },
+ };
+ }
+
+ // Objects in this array should contain the following properties:
+ // - desc {String} For logging
+ // - animation {Object} An animation object instantiated from one of the mock
+ // window animation constructors.
+ // - props {Objet} Properties of this object will be added to the animation
+ // object.
+ // - expectedName {String} The expected name returned by
+ // AnimationPlayerActor.getName.
+ const TEST_DATA = [
+ {
+ desc: "Animation with an id",
+ animation: new window.Animation(),
+ props: { id: "animation-id" },
+ expectedName: "animation-id",
+ },
+ {
+ desc: "Animation without an id",
+ animation: new window.Animation(),
+ props: {},
+ expectedName: "",
+ },
+ {
+ desc: "CSSTransition with an id",
+ animation: new window.CSSTransition(),
+ props: { id: "transition-with-id", transitionProperty: "width" },
+ expectedName: "transition-with-id",
+ },
+ {
+ desc: "CSSAnimation with an id",
+ animation: new window.CSSAnimation(),
+ props: { id: "animation-with-id", animationName: "move" },
+ expectedName: "animation-with-id",
+ },
+ {
+ desc: "CSSTransition without an id",
+ animation: new window.CSSTransition(),
+ props: { transitionProperty: "width" },
+ expectedName: "width",
+ },
+ {
+ desc: "CSSAnimation without an id",
+ animation: new window.CSSAnimation(),
+ props: { animationName: "move" },
+ expectedName: "move",
+ },
+ ];
+
+ for (const { desc, animation, props, expectedName } of TEST_DATA) {
+ info(desc);
+ for (const key in props) {
+ animation[key] = props[key];
+ }
+ const actor = new AnimationPlayerActor({}, animation);
+ Assert.equal(actor.getName(), expectedName);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_animation_type.js b/devtools/server/tests/xpcshell/test_animation_type.js
new file mode 100644
index 0000000000..261b5ef2ac
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_animation_type.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test the output of AnimationPlayerActor.getType().
+
+const {
+ ANIMATION_TYPES,
+ AnimationPlayerActor,
+} = require("resource://devtools/server/actors/animation.js");
+
+function run_test() {
+ // Mock a window with just the properties the AnimationPlayerActor uses.
+ const window = {};
+ window.MutationObserver = class {
+ constructor() {
+ this.observe = () => {};
+ }
+ };
+ window.Animation = class {
+ constructor() {
+ this.effect = { target: getMockNode() };
+ }
+ };
+
+ window.CSSAnimation = class extends window.Animation {};
+ window.CSSTransition = class extends window.Animation {};
+
+ // Helper to get a mock DOM node.
+ function getMockNode() {
+ return {
+ ownerDocument: {
+ defaultView: window,
+ },
+ };
+ }
+
+ // Objects in this array should contain the following properties:
+ // - desc {String} For logging
+ // - animation {Object} An animation object instantiated from one of the mock
+ // window animation constructors.
+ // - expectedType {String} The expected type returned by
+ // AnimationPlayerActor.getType.
+ const TEST_DATA = [
+ {
+ desc: "Test CSSAnimation type",
+ animation: new window.CSSAnimation(),
+ expectedType: ANIMATION_TYPES.CSS_ANIMATION,
+ },
+ {
+ desc: "Test CSSTransition type",
+ animation: new window.CSSTransition(),
+ expectedType: ANIMATION_TYPES.CSS_TRANSITION,
+ },
+ {
+ desc: "Test ScriptAnimation type",
+ animation: new window.Animation(),
+ expectedType: ANIMATION_TYPES.SCRIPT_ANIMATION,
+ },
+ {
+ desc: "Test unknown type",
+ animation: { effect: { target: getMockNode() } },
+ expectedType: ANIMATION_TYPES.UNKNOWN,
+ },
+ ];
+
+ for (const { desc, animation, expectedType } of TEST_DATA) {
+ info(desc);
+ const actor = new AnimationPlayerActor({}, animation);
+ Assert.equal(actor.getType(), expectedType);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_attach.js b/devtools/server/tests/xpcshell/test_attach.js
new file mode 100644
index 0000000000..fb7d232e76
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_attach.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ThreadFront } = require("resource://devtools/client/fronts/thread.js");
+const {
+ WindowGlobalTargetFront,
+} = require("resource://devtools/client/fronts/targets/window-global.js");
+
+/**
+ * Very naive test that checks threadClearTest helper.
+ * It ensures that the thread front is correctly attached.
+ */
+add_task(
+ threadFrontTest(({ threadFront, debuggee, client, targetFront }) => {
+ ok(true, "Thread actor was able to attach");
+ ok(threadFront instanceof ThreadFront, "Thread Front is valid");
+ Assert.equal(threadFront.state, "attached", "Thread Front is resumed");
+ Assert.equal(
+ Cu.getSandboxMetadata(debuggee),
+ undefined,
+ "Debuggee client is valid (getSandboxMetadata did not fail)"
+ );
+ ok(client instanceof DevToolsClient, "Client is valid");
+ ok(targetFront instanceof WindowGlobalTargetFront, "TargetFront is valid");
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_blackboxing-01.js b/devtools/server/tests/xpcshell/test_blackboxing-01.js
new file mode 100644
index 0000000000..6c549b908e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_blackboxing-01.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test basic black boxing.
+ */
+
+var gDebuggee;
+var gThreadFront;
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ gThreadFront = threadFront;
+ gDebuggee = debuggee;
+ await testBlackBox();
+ })
+);
+
+const BLACK_BOXED_URL = "http://example.com/blackboxme.js";
+const SOURCE_URL = "http://example.com/source.js";
+
+const testBlackBox = async function () {
+ const packet = await executeOnNextTickAndWaitForPause(evalCode, gThreadFront);
+
+ const bpSource = await getSourceById(gThreadFront, packet.frame.where.actor);
+
+ await setBreakpoint(gThreadFront, { sourceUrl: bpSource.url, line: 2 });
+ await resume(gThreadFront);
+
+ let sourceForm = await getSourceForm(gThreadFront, BLACK_BOXED_URL);
+
+ Assert.ok(
+ !sourceForm.isBlackBoxed,
+ "By default the source is not black boxed."
+ );
+
+ // Test that we can step into `doStuff` when we are not black boxed.
+ await runTest(
+ async function onSteppedLocation(location) {
+ const source = await getSourceFormById(gThreadFront, location.actor);
+ Assert.equal(source.url, BLACK_BOXED_URL);
+ Assert.equal(location.line, 2);
+ },
+ async function onDebuggerStatementFrames(frames) {
+ for (const frame of frames) {
+ const source = await getSourceFormById(gThreadFront, frame.where.actor);
+ Assert.ok(!source.isBlackBoxed);
+ }
+ }
+ );
+
+ const blackboxedSource = await getSource(gThreadFront, BLACK_BOXED_URL);
+ await blackBox(blackboxedSource);
+ sourceForm = await getSourceForm(gThreadFront, BLACK_BOXED_URL);
+ Assert.ok(sourceForm.isBlackBoxed);
+
+ // Test that we step through `doStuff` when we are black boxed and its frame
+ // doesn't show up.
+ await runTest(
+ async function onSteppedLocation(location) {
+ const source = await getSourceFormById(gThreadFront, location.actor);
+ Assert.equal(source.url, SOURCE_URL);
+ Assert.equal(location.line, 4);
+ },
+ async function onDebuggerStatementFrames(frames) {
+ for (const frame of frames) {
+ const source = await getSourceFormById(gThreadFront, frame.where.actor);
+ if (source.url == BLACK_BOXED_URL) {
+ Assert.ok(source.isBlackBoxed);
+ } else {
+ Assert.ok(!source.isBlackBoxed);
+ }
+ }
+ }
+ );
+
+ await unBlackBox(blackboxedSource);
+ sourceForm = await getSourceForm(gThreadFront, BLACK_BOXED_URL);
+ Assert.ok(!sourceForm.isBlackBoxed);
+
+ // Test that we can step into `doStuff` again.
+ await runTest(
+ async function onSteppedLocation(location) {
+ const source = await getSourceFormById(gThreadFront, location.actor);
+ Assert.equal(source.url, BLACK_BOXED_URL);
+ Assert.equal(location.line, 2);
+ },
+ async function onDebuggerStatementFrames(frames) {
+ for (const frame of frames) {
+ const source = await getSourceFormById(gThreadFront, frame.where.actor);
+ Assert.ok(!source.isBlackBoxed);
+ }
+ }
+ );
+};
+
+function evalCode() {
+ /* eslint-disable mozilla/var-only-at-top-level, no-undef */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" + function doStuff(k) { // line 1
+ var arg = 15; // line 2 - Step in here
+ k(arg); // line 3
+ }, // line 4
+ gDebuggee,
+ "1.8",
+ BLACK_BOXED_URL,
+ 1
+ );
+
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" + function runTest() { // line 1
+ doStuff( // line 2 - Break here
+ function (n) { // line 3 - Step through `doStuff` to here
+ (() => {})(); // line 4
+ debugger; // line 5
+ } // line 6
+ ); // line 7
+ } + "\n" // line 8
+ + "debugger;", // line 9
+ gDebuggee,
+ "1.8",
+ SOURCE_URL,
+ 1
+ );
+}
+
+const runTest = async function (onSteppedLocation, onDebuggerStatementFrames) {
+ let packet = await executeOnNextTickAndWaitForPause(
+ gDebuggee.runTest,
+ gThreadFront
+ );
+ Assert.equal(packet.why.type, "breakpoint");
+
+ await stepIn(gThreadFront);
+
+ const location = await getCurrentLocation();
+ await onSteppedLocation(location);
+
+ packet = await resumeAndWaitForPause(gThreadFront);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ const { frames } = await getFrames(gThreadFront, 0, 100);
+ await onDebuggerStatementFrames(frames);
+
+ return resume(gThreadFront);
+};
+
+const getCurrentLocation = async function () {
+ const response = await getFrames(gThreadFront, 0, 1);
+ return response.frames[0].where;
+};
diff --git a/devtools/server/tests/xpcshell/test_blackboxing-02.js b/devtools/server/tests/xpcshell/test_blackboxing-02.js
new file mode 100644
index 0000000000..66efaee6c8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_blackboxing-02.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that we don't hit breakpoints in black boxed sources, and that when we
+ * unblack box the source again, the breakpoint hasn't disappeared and we will
+ * hit it again.
+ */
+
+const BLACK_BOXED_URL = "http://example.com/blackboxme.js";
+const SOURCE_URL = "http://example.com/source.js";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ // Set up
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ threadFront.setBreakpoint({ sourceUrl: BLACK_BOXED_URL, line: 2 }, {});
+ await threadFront.resume();
+
+ // Test the breakpoint in the black boxed source
+ const { error, sources } = await threadFront.getSources();
+ Assert.ok(!error, "Should not get an error: " + error);
+ const sourceFront = threadFront.source(
+ sources.filter(s => s.url == BLACK_BOXED_URL)[0]
+ );
+
+ await blackBox(sourceFront);
+
+ const packet1 = await executeOnNextTickAndWaitForPause(
+ debuggee.runTest,
+ threadFront
+ );
+
+ Assert.equal(
+ packet1.why.type,
+ "debuggerStatement",
+ "We should pass over the breakpoint since the source is black boxed."
+ );
+
+ await threadFront.resume();
+
+ // Test the breakpoint in the unblack boxed source
+ await unBlackBox(sourceFront);
+
+ const packet2 = await executeOnNextTickAndWaitForPause(
+ debuggee.runTest,
+ threadFront
+ );
+
+ Assert.equal(
+ packet2.why.type,
+ "breakpoint",
+ "We should hit the breakpoint again"
+ );
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable no-undef */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" + function doStuff(k) { // line 1
+ const arg = 15; // line 2 - Break here
+ k(arg); // line 3
+ }, // line 4
+ debuggee,
+ "1.8",
+ BLACK_BOXED_URL,
+ 1
+ );
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" + function runTest() { // line 1
+ doStuff( // line 2
+ function(n) { // line 3
+ debugger; // line 5
+ } // line 6
+ ); // line 7
+ } // line 8
+ + "\n debugger;", // line 9
+ debuggee,
+ "1.8",
+ SOURCE_URL,
+ 1
+ );
+ /* eslint-enable no-undef */
+}
diff --git a/devtools/server/tests/xpcshell/test_blackboxing-03.js b/devtools/server/tests/xpcshell/test_blackboxing-03.js
new file mode 100644
index 0000000000..f97c8e70f4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_blackboxing-03.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that we don't stop at debugger statements inside black boxed sources.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ // Set up
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ threadFront.setBreakpoint({ sourceUrl: source.url, line: 4 }, {});
+ await threadFront.resume();
+
+ // Test the debugger statement in the black boxed source
+ await threadFront.getSources();
+ const sourceFront = await getSource(threadFront, BLACK_BOXED_URL);
+
+ await blackBox(sourceFront);
+
+ const packet2 = await executeOnNextTickAndWaitForPause(
+ debuggee.runTest,
+ threadFront
+ );
+
+ Assert.equal(
+ packet2.why.type,
+ "breakpoint",
+ "We should pass over the debugger statement."
+ );
+
+ threadFront.removeBreakpoint({ sourceUrl: source.url, line: 4 }, {});
+
+ await threadFront.resume();
+
+ // Test the debugger statement in the unblack boxed source
+ await unBlackBox(sourceFront);
+
+ const packet3 = await executeOnNextTickAndWaitForPause(
+ debuggee.runTest,
+ threadFront
+ );
+
+ Assert.equal(
+ packet3.why.type,
+ "debuggerStatement",
+ "We should stop at the debugger statement again"
+ );
+ await threadFront.resume();
+
+ // Test the debugger statement in the black boxed range
+ threadFront.setBreakpoint({ sourceUrl: source.url, line: 4 }, {});
+
+ await blackBox(sourceFront, {
+ start: { line: 1, column: 0 },
+ end: { line: 9, column: 0 },
+ });
+
+ const packet4 = await executeOnNextTickAndWaitForPause(
+ debuggee.runTest,
+ threadFront
+ );
+
+ Assert.equal(
+ packet4.why.type,
+ "breakpoint",
+ "We should pass over the debugger statement."
+ );
+
+ threadFront.removeBreakpoint({ sourceUrl: source.url, line: 4 }, {});
+ await unBlackBox(sourceFront);
+ await threadFront.resume();
+ })
+);
+
+const BLACK_BOXED_URL = "http://example.com/blackboxme.js";
+const SOURCE_URL = "http://example.com/source.js";
+
+function evalCode(debuggee) {
+ /* eslint-disable no-multi-spaces, no-undef */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" + function doStuff(k) { // line 1
+ debugger; // line 2 - Break here
+ k(100); // line 3
+ }, // line 4
+ debuggee,
+ "1.8",
+ BLACK_BOXED_URL,
+ 1
+ );
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" + function runTest() { // line 1
+ doStuff( // line 2
+ function(n) { // line 3
+ Math.abs(n); // line 4 - Break here
+ } // line 5
+ ); // line 6
+ } // line 7
+ + "\n debugger;", // line 8
+ debuggee,
+ "1.8",
+ SOURCE_URL,
+ 1
+ );
+ /* eslint-enable no-multi-spaces, no-undef */
+}
diff --git a/devtools/server/tests/xpcshell/test_blackboxing-04.js b/devtools/server/tests/xpcshell/test_blackboxing-04.js
new file mode 100644
index 0000000000..13345c40e8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_blackboxing-04.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test behavior of blackboxing sources we are currently paused in.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ // Set up
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ threadFront.setBreakpoint({ sourceUrl: BLACK_BOXED_URL, line: 2 }, {});
+
+ // Test black boxing a source while pausing in the source
+ const { error, sources } = await threadFront.getSources();
+ Assert.ok(!error, "Should not get an error: " + error);
+ const sourceFront = threadFront.source(
+ sources.filter(s => s.url == BLACK_BOXED_URL)[0]
+ );
+
+ const pausedInSource = await blackBox(sourceFront);
+ Assert.ok(
+ pausedInSource,
+ "We should be notified that we are currently paused in this source"
+ );
+ await threadFront.resume();
+ })
+);
+
+const BLACK_BOXED_URL = "http://example.com/blackboxme.js";
+const SOURCE_URL = "http://example.com/source.js";
+
+function evalCode(debuggee) {
+ /* eslint-disable no-multi-spaces, no-undef */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" +
+ function doStuff(k) { // line 1
+ debugger; // line 2
+ k(100); // line 3
+ }, // line 4
+ debuggee,
+ "1.8",
+ BLACK_BOXED_URL,
+ 1
+ );
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" +
+ function runTest() { // line 1
+ doStuff( // line 2
+ function(n) { // line 3
+ return n; // line 4
+ } // line 5
+ ); // line 6
+ } + // line 7
+ "\n runTest();", // line 8
+ debuggee,
+ "1.8",
+ SOURCE_URL,
+ 1
+ );
+ /* eslint-enable no-multi-spaces, no-undef */
+}
diff --git a/devtools/server/tests/xpcshell/test_blackboxing-05.js b/devtools/server/tests/xpcshell/test_blackboxing-05.js
new file mode 100644
index 0000000000..388c87da88
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_blackboxing-05.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test exceptions inside black boxed sources.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, commands }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const { error } = await threadFront.getSources();
+ Assert.ok(!error, "Should not get an error: " + error);
+
+ const sourceFront = await getSource(threadFront, BLACK_BOXED_URL);
+ await blackBox(sourceFront);
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: false,
+ });
+
+ const packet = await resumeAndWaitForPause(threadFront);
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+
+ Assert.equal(
+ source.url,
+ SOURCE_URL,
+ "We shouldn't pause while in the black boxed source."
+ );
+
+ await unBlackBox(sourceFront);
+ await blackBox(sourceFront, {
+ start: { line: 1, column: 0 },
+ end: { line: 4, column: 0 },
+ });
+
+ await threadFront.resume();
+
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ const source2 = await getSourceById(threadFront, packet2.frame.where.actor);
+
+ Assert.equal(
+ source2.url,
+ SOURCE_URL,
+ "We shouldn't pause while in the black boxed source."
+ );
+
+ await threadFront.resume();
+ })
+);
+
+const BLACK_BOXED_URL = "http://example.com/blackboxme.js";
+const SOURCE_URL = "http://example.com/source.js";
+
+function evalCode(debuggee) {
+ /* eslint-disable no-multi-spaces, no-unreachable, no-undef */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" +
+ function doStuff(k) { // line 1
+ throw new Error("error msg"); // line 2
+ k(100); // line 3
+ }, // line 4
+ debuggee,
+ "1.8",
+ BLACK_BOXED_URL,
+ 1
+ );
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" +
+ function runTest() { // line 1
+ doStuff( // line 2
+ function(n) { // line 3
+ debugger; // line 4
+ } // line 5
+ ); // line 6
+ } + // line 7
+ "\ndebugger;\n" + // line 8
+ "try { runTest() } catch (ex) { }", // line 9
+ debuggee,
+ "1.8",
+ SOURCE_URL,
+ 1
+ );
+ /* eslint-enable no-multi-spaces, no-unreachable, no-undef */
+}
diff --git a/devtools/server/tests/xpcshell/test_blackboxing-08.js b/devtools/server/tests/xpcshell/test_blackboxing-08.js
new file mode 100644
index 0000000000..d20d8b3966
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_blackboxing-08.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test blackbox ranges
+ */
+
+async function testFinish({ threadFront, devToolsClient }) {
+ await threadFront.resume();
+ await close(devToolsClient);
+
+ do_test_finished();
+}
+
+async function invokeAndPause({ global, threadFront }, expression) {
+ return executeOnNextTickAndWaitForPause(
+ () => Cu.evalInSandbox(expression, global),
+ threadFront
+ );
+}
+
+function run_test() {
+ return (async function () {
+ const dbg = await setupTestFromUrl("stepping.js");
+ const { threadFront } = dbg;
+
+ await invokeAndPause(dbg, `chaining()`);
+
+ const { sources } = await getSources(threadFront);
+ const sourceFront = threadFront.source(sources[0]);
+
+ await setBreakpoint(threadFront, { sourceUrl: sourceFront.url, line: 7 });
+ await setBreakpoint(threadFront, { sourceUrl: sourceFront.url, line: 11 });
+
+ // 1. lets blackbox function a, and assert that we pause in b
+ const range = { start: { line: 6, column: 0 }, end: { line: 8, colum: 1 } };
+ blackBox(sourceFront, range);
+ const paused = await resumeAndWaitForPause(threadFront);
+ equal(paused.frame.where.line, 11, "paused inside of b");
+ await threadFront.resume();
+
+ // 2. lets unblackbox function a, and assert that we pause in a
+ unBlackBox(sourceFront, range);
+ await invokeAndPause(dbg, `chaining()`);
+ const paused2 = await resumeAndWaitForPause(threadFront);
+ equal(paused2.frame.where.line, 7, "paused inside of a");
+
+ await testFinish(dbg);
+ })();
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-01.js b/devtools/server/tests/xpcshell/test_breakpoint-01.js
new file mode 100644
index 0000000000..be46d97cfb
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-01.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check basic breakpoint functionality.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ info("Wait for the debugger statement to be hit");
+ const packet1 = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ const source = await getSourceById(threadFront, packet1.frame.where.actor);
+ const location = { sourceUrl: source.url, line: debuggee.line0 + 3 };
+
+ threadFront.setBreakpoint(location, {});
+
+ const packet2 = await resumeAndWaitForPause(threadFront);
+
+ info("Paused at the breakpoint");
+ Assert.equal(packet2.frame.where.actor, source.actor);
+ Assert.equal(packet2.frame.where.line, location.line);
+ Assert.equal(packet2.why.type, "breakpoint");
+
+ info("Check that the breakpoint worked.");
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ /*
+ * Be sure to run debuggee code in its own HTML 'task', so that when we call
+ * the onDebuggerStatement hook, the test's own microtasks don't get suspended
+ * along with the debuggee's.
+ */
+ do_timeout(0, () => {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "debugger;\n" + // line0 + 1
+ "var a = 1;\n" + // line0 + 2
+ "var b = 2;\n", // line0 + 3
+ debuggee
+ );
+ });
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-03.js b/devtools/server/tests/xpcshell/test_breakpoint-03.js
new file mode 100644
index 0000000000..f598660a98
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-03.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that setting a breakpoint on a line without code will skip
+ * forward when we know the script isn't GCed (the debugger is connected,
+ * so it's kept alive).
+ */
+
+var test_no_skip_breakpoint = async function (source, location, debuggee) {
+ const [response, bpClient] = await source.setBreakpoint(
+ Object.assign({}, location, { noSliding: true })
+ );
+
+ Assert.ok(!response.actualLocation);
+ Assert.equal(bpClient.location.line, debuggee.line0 + 3);
+ await bpClient.remove();
+};
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee }) => {
+ return new Promise(resolve => {
+ threadFront.once("paused", async function (packet) {
+ const location = { line: debuggee.line0 + 3 };
+ const source = await getSourceById(
+ threadFront,
+ packet.frame.where.actor
+ );
+ // First, make sure that we can disable sliding with the
+ // `noSliding` option.
+ await test_no_skip_breakpoint(source, location, debuggee);
+
+ // Now make sure that the breakpoint properly slides forward one line.
+ const [response, bpClient] = await source.setBreakpoint(location);
+ Assert.ok(!!response.actualLocation);
+ Assert.equal(response.actualLocation.source.actor, source.actor);
+ Assert.equal(response.actualLocation.line, location.line + 1);
+
+ threadFront.once("paused", function (packet) {
+ // Check the return value.
+ Assert.equal(packet.frame.where.actor, source.actor);
+ Assert.equal(packet.frame.where.line, location.line + 1);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.why.actors[0], bpClient.actor);
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ // Remove the breakpoint.
+ bpClient.remove(function (response) {
+ threadFront.resume().then(resolve);
+ });
+ });
+
+ threadFront.resume();
+ });
+
+ // Use `evalInSandbox` to make the debugger treat it as normal
+ // globally-scoped code, where breakpoint sliding rules apply.
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "debugger;\n" + // line0 + 1
+ "var a = 1;\n" + // line0 + 2
+ "// A comment.\n" + // line0 + 3
+ "var b = 2;", // line0 + 4
+ debuggee
+ );
+ });
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-04.js b/devtools/server/tests/xpcshell/test_breakpoint-04.js
new file mode 100644
index 0000000000..8b7137f85d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-04.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that setting a breakpoint in a line in a child script works.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, client, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = { sourceUrl: source.url, line: debuggee.line0 + 3 };
+
+ //Pause at debugger statement.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 5);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ threadFront.setBreakpoint(location, {});
+ await client.waitForRequestsToSettle();
+ await resume(threadFront);
+
+ const packet2 = await waitForPause(threadFront);
+ // Check the return value.
+ Assert.equal(packet2.frame.where.actor, source.actor);
+ Assert.equal(packet2.frame.where.line, location.line);
+ Assert.equal(packet2.why.type, "breakpoint");
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ // Remove the breakpoint.
+ threadFront.removeBreakpoint(location);
+ await client.waitForRequestsToSettle();
+
+ await resume(threadFront);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "function foo() {\n" + // line0 + 1
+ " this.a = 1;\n" + // line0 + 2
+ " this.b = 2;\n" + // line0 + 3
+ "}\n" + // line0 + 4
+ "debugger;\n" + // line0 + 5
+ "foo();\n", // line0 + 6
+ debuggee
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-05.js b/devtools/server/tests/xpcshell/test_breakpoint-05.js
new file mode 100644
index 0000000000..f678b285b1
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-05.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that setting a breakpoint in a line without code in a child script
+ * will skip forward.
+ */
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee }) => {
+ return new Promise(resolve => {
+ threadFront.once("paused", async function (packet) {
+ const source = await getSourceById(
+ threadFront,
+ packet.frame.where.actor
+ );
+ const location = { line: debuggee.line0 + 3 };
+
+ source.setBreakpoint(location).then(function ([response, bpClient]) {
+ // Check that the breakpoint has properly skipped forward one line.
+ Assert.equal(response.actualLocation.source.actor, source.actor);
+ Assert.equal(response.actualLocation.line, location.line + 1);
+
+ threadFront.once("paused", function (packet) {
+ // Check the return value.
+ Assert.equal(packet.frame.where.actor, source.actor);
+ Assert.equal(packet.frame.where.line, location.line + 1);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.why.actors[0], bpClient.actor);
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ // Remove the breakpoint.
+ bpClient.remove(function (response) {
+ threadFront.resume().then(resolve);
+ });
+ });
+
+ // Continue until the breakpoint is hit.
+ threadFront.resume();
+ });
+ });
+
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "function foo() {\n" + // line0 + 1
+ " this.a = 1;\n" + // line0 + 2
+ " // A comment.\n" + // line0 + 3
+ " this.b = 2;\n" + // line0 + 4
+ "}\n" + // line0 + 5
+ "debugger;\n" + // line0 + 6
+ "foo();\n", // line0 + 7
+ debuggee
+ );
+ });
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-06.js b/devtools/server/tests/xpcshell/test_breakpoint-06.js
new file mode 100644
index 0000000000..79ddcdc3d4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-06.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that setting a breakpoint in a line without code in a deeply-nested
+ * child script will skip forward.
+ */
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee }) => {
+ return new Promise(resolve => {
+ threadFront.once("paused", async function (packet) {
+ const source = await getSourceById(
+ threadFront,
+ packet.frame.where.actor
+ );
+ const location = { line: debuggee.line0 + 5 };
+
+ source.setBreakpoint(location).then(function ([response, bpClient]) {
+ // Check that the breakpoint has properly skipped forward one line.
+ Assert.equal(response.actualLocation.source.actor, source.actor);
+ Assert.equal(response.actualLocation.line, location.line + 1);
+
+ threadFront.once("paused", function (packet) {
+ // Check the return value.
+ Assert.equal(packet.frame.where.actor, source.actor);
+ Assert.equal(packet.frame.where.line, location.line + 1);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.why.actors[0], bpClient.actor);
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ // Remove the breakpoint.
+ bpClient.remove(function (response) {
+ threadFront.resume().then(resolve);
+ });
+ });
+
+ // Continue until the breakpoint is hit.
+ threadFront.resume();
+ });
+ });
+
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "function foo() {\n" + // line0 + 1
+ " function bar() {\n" + // line0 + 2
+ " function baz() {\n" + // line0 + 3
+ " this.a = 1;\n" + // line0 + 4
+ " // A comment.\n" + // line0 + 5
+ " this.b = 2;\n" + // line0 + 6
+ " }\n" + // line0 + 7
+ " baz();\n" + // line0 + 8
+ " }\n" + // line0 + 9
+ " bar();\n" + // line0 + 10
+ "}\n" + // line0 + 11
+ "debugger;\n" + // line0 + 12
+ "foo();\n", // line0 + 13
+ debuggee
+ );
+ });
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-07.js b/devtools/server/tests/xpcshell/test_breakpoint-07.js
new file mode 100644
index 0000000000..e6391747bb
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-07.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that setting a breakpoint in a line without code in the second child
+ * script will skip forward.
+ */
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee }) => {
+ return new Promise(resolve => {
+ threadFront.once("paused", async function (packet) {
+ const source = await getSourceById(
+ threadFront,
+ packet.frame.where.actor
+ );
+ const location = { line: debuggee.line0 + 6 };
+
+ source.setBreakpoint(location).then(function ([response, bpClient]) {
+ // Check that the breakpoint has properly skipped forward one line.
+ Assert.equal(response.actualLocation.source.actor, source.actor);
+ Assert.equal(response.actualLocation.line, location.line + 1);
+
+ threadFront.once("paused", function (packet) {
+ // Check the return value.
+ Assert.equal(packet.frame.where.actor, source.actor);
+ Assert.equal(packet.frame.where.line, location.line + 1);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.why.actors[0], bpClient.actor);
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ // Remove the breakpoint.
+ bpClient.remove(function (response) {
+ threadFront.resume().then(resolve);
+ });
+ });
+
+ // Continue until the breakpoint is hit.
+ threadFront.resume();
+ });
+ });
+
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "function foo() {\n" + // line0 + 1
+ " bar();\n" + // line0 + 2
+ "}\n" + // line0 + 3
+ "function bar() {\n" + // line0 + 4
+ " this.a = 1;\n" + // line0 + 5
+ " // A comment.\n" + // line0 + 6
+ " this.b = 2;\n" + // line0 + 7
+ "}\n" + // line0 + 8
+ "debugger;\n" + // line0 + 9
+ "foo();\n", // line0 + 10
+ debuggee
+ );
+ });
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-08.js b/devtools/server/tests/xpcshell/test_breakpoint-08.js
new file mode 100644
index 0000000000..bff0cc3b52
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-08.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that setting a breakpoint in a line without code in a child script
+ * will skip forward, in a file with two scripts.
+ */
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee }) => {
+ return new Promise(resolve => {
+ threadFront.once("paused", async function (packet) {
+ const line = debuggee.line0 + 3;
+ const source = await getSourceById(
+ threadFront,
+ packet.frame.where.actor
+ );
+
+ // this test has been disabled for a long time so the functionality doesn't work
+ const response = await threadFront.setBreakpoint(
+ { sourceUrl: source.url, line },
+ {}
+ );
+ // check that the breakpoint has properly skipped forward one line.
+ assert.equal(response.actuallocation.source.actor, source.actor);
+ // This is wrong - location is not defined, but the test has been disabled
+ // for a long time and currently doesn't work.
+ // eslint-disable-next-line no-undef
+ Assert.equal(response.actualLocation.line, location.line + 1);
+
+ threadFront.once("paused", function (packet) {
+ // Check the return value.
+ Assert.equal(packet.frame.where.actor, source.actor);
+ // eslint-disable-next-line no-undef
+ Assert.equal(packet.frame.where.line, location.line + 1);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.why.actors[0], response.bpClient.actor);
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ // Remove the breakpoint.
+ response.bpClient.remove(function (response) {
+ threadFront.resume().then(resolve);
+ });
+ });
+
+ // Continue until the breakpoint is hit.
+ threadFront.resume();
+ });
+
+ // prettier-ignore
+ Cu.evalInSandbox("var line0 = Error().lineNumber;\n" +
+ "function foo() {\n" + // line0 + 1
+ " this.a = 1;\n" + // line0 + 2
+ " // A comment.\n" + // line0 + 3
+ " this.b = 2;\n" + // line0 + 4
+ "}\n", // line0 + 5
+ debuggee,
+ "1.7",
+ "script1.js");
+
+ // prettier-ignore
+ Cu.evalInSandbox("var line1 = Error().lineNumber;\n" +
+ "debugger;\n" + // line1 + 1
+ "foo();\n", // line1 + 2
+ debuggee,
+ "1.7",
+ "script2.js");
+ });
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-09.js b/devtools/server/tests/xpcshell/test_breakpoint-09.js
new file mode 100644
index 0000000000..90b334102d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-09.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that removing a breakpoint works.
+ */
+
+let done = false;
+
+add_task(
+ threadFrontTest(async ({ threadFront, client, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = { sourceUrl: source.url, line: debuggee.line0 + 2 };
+
+ //Pause at debugger statement.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 7);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ threadFront.setBreakpoint(location, {});
+ await client.waitForRequestsToSettle();
+
+ await resume(threadFront);
+
+ const packet2 = await waitForPause(threadFront);
+
+ // Check the return value.
+ Assert.equal(packet2.frame.where.actor, source.actorID);
+ Assert.equal(packet2.frame.where.line, location.line);
+ Assert.equal(packet2.why.type, "breakpoint");
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, undefined);
+
+ // Remove the breakpoint.
+ threadFront.removeBreakpoint(location);
+ await client.waitForRequestsToSettle();
+
+ done = true;
+ threadFront.once("paused", function (packet) {
+ // The breakpoint should not be hit again.
+ threadFront.resume().then(function () {
+ Assert.ok(false);
+ });
+ });
+
+ await resume(threadFront);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox("var line0 = Error().lineNumber;\n" +
+ "function foo(stop) {\n" + // line0 + 1
+ " this.a = 1;\n" + // line0 + 2
+ " if (stop) return;\n" + // line0 + 3
+ " delete this.a;\n" + // line0 + 4
+ " foo(true);\n" + // line0 + 5
+ "}\n" + // line0 + 6
+ "debugger;\n" + // line0 + 7
+ "foo();\n", // line0 + 8
+ debuggee);
+ if (!done) {
+ Assert.ok(false);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-10.js b/devtools/server/tests/xpcshell/test_breakpoint-10.js
new file mode 100644
index 0000000000..fd114f173d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-10.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that setting a breakpoint in a line with multiple entry points
+ * triggers no matter which entry point we reach.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, client, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = {
+ sourceUrl: source.url,
+ line: debuggee.line0 + 3,
+ column: 5,
+ };
+
+ //Pause at debugger statement.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 1);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ threadFront.setBreakpoint(location, {});
+ await client.waitForRequestsToSettle();
+
+ await resume(threadFront);
+
+ const packet2 = await waitForPause(threadFront);
+ // Check the return value.
+ Assert.equal(packet2.why.type, "breakpoint");
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.i, 0);
+ // Check pause location
+ Assert.equal(packet2.frame.where.line, debuggee.line0 + 3);
+ Assert.equal(packet2.frame.where.column, 5);
+
+ // Remove the breakpoint.
+ threadFront.removeBreakpoint(location);
+ await client.waitForRequestsToSettle();
+
+ const location2 = {
+ sourceUrl: source.url,
+ line: debuggee.line0 + 3,
+ column: 12,
+ };
+ threadFront.setBreakpoint(location2, {});
+ await client.waitForRequestsToSettle();
+
+ await resume(threadFront);
+ const packet3 = await waitForPause(threadFront);
+ // Check the return value.
+ Assert.equal(packet3.why.type, "breakpoint");
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.i, 1);
+ // Check execution location
+ Assert.equal(packet3.frame.where.line, debuggee.line0 + 3);
+ Assert.equal(packet3.frame.where.column, 12);
+
+ // Remove the breakpoint.
+ threadFront.removeBreakpoint(location2);
+ await client.waitForRequestsToSettle();
+
+ await resume(threadFront);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox("var line0 = Error().lineNumber;\n" +
+ "debugger;\n" + // line0 + 1
+ "var a, i = 0;\n" + // line0 + 2
+ "for (i = 1; i <= 2; i++) {\n" + // line0 + 3
+ " a = i;\n" + // line0 + 4
+ "}\n", // line0 + 5
+ debuggee);
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-11.js b/devtools/server/tests/xpcshell/test_breakpoint-11.js
new file mode 100644
index 0000000000..a29cd2f768
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-11.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Make sure that setting a breakpoint in a line with bytecodes in multiple
+ * scripts, sets the breakpoint in all of them (bug 793214).
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, client, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = {
+ sourceUrl: source.url,
+ line: debuggee.line0 + 2,
+ column: 8,
+ };
+
+ //Pause at debugger statement.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 1);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ threadFront.setBreakpoint(location, {});
+ await resume(threadFront);
+
+ const packet2 = await waitForPause(threadFront);
+
+ // Check the return value.
+ Assert.equal(packet2.why.type, "breakpoint");
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, undefined);
+ // Check execution location
+ Assert.equal(packet2.frame.where.line, debuggee.line0 + 2);
+ Assert.equal(packet2.frame.where.column, 8);
+
+ // Remove the breakpoint.
+ threadFront.removeBreakpoint(location);
+
+ const location2 = {
+ sourceUrl: source.url,
+ line: debuggee.line0 + 2,
+ column: 32,
+ };
+ threadFront.setBreakpoint(location2, {});
+
+ await resume(threadFront);
+ const packet3 = await waitForPause(threadFront);
+
+ // Check the return value.
+ Assert.equal(packet3.why.type, "breakpoint");
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a.b, 1);
+ Assert.equal(debuggee.res, undefined);
+ // Check execution location
+ Assert.equal(packet3.frame.where.line, debuggee.line0 + 2);
+ Assert.equal(packet3.frame.where.column, 32);
+
+ // Remove the breakpoint.
+ threadFront.removeBreakpoint(location2);
+
+ await resume(threadFront);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox("var line0 = Error().lineNumber;\n" +
+ "debugger;\n" + // line0 + 1
+ "var a = { b: 1, f: function() { return 2; } };\n" + // line0+2
+ "var res = a.f();\n", // line0 + 3
+ debuggee);
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-12.js b/devtools/server/tests/xpcshell/test_breakpoint-12.js
new file mode 100644
index 0000000000..44b524f1cf
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-12.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Make sure that setting a breakpoint twice in a line without bytecodes works
+ * as expected.
+ */
+
+const NUM_BREAKPOINTS = 10;
+var gBpActor;
+var gCount;
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee }) => {
+ return new Promise(resolve => {
+ threadFront.once("paused", async function (packet) {
+ const source = await getSourceById(
+ threadFront,
+ packet.frame.where.actor
+ );
+ const location = { line: debuggee.line0 + 3 };
+
+ source.setBreakpoint(location).then(function ([response, bpClient]) {
+ // Check that the breakpoint has properly skipped forward one line.
+ Assert.equal(response.actualLocation.source.actor, source.actor);
+ Assert.equal(response.actualLocation.line, location.line + 1);
+ gBpActor = response.actor;
+
+ // Set more breakpoints at the same location.
+ set_breakpoints(source, location);
+ });
+ });
+
+ /* eslint-disable no-multi-spaces */
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "function foo() {\n" + // line0 + 1
+ " this.a = 1;\n" + // line0 + 2
+ " // A comment.\n" + // line0 + 3
+ " this.b = 2;\n" + // line0 + 4
+ "}\n" + // line0 + 5
+ "debugger;\n" + // line0 + 6
+ "foo();\n", // line0 + 7
+ debuggee
+ );
+ /* eslint-enable no-multi-spaces */
+
+ // Set many breakpoints at the same location.
+ function set_breakpoints(source, location) {
+ Assert.notEqual(gCount, NUM_BREAKPOINTS);
+ source.setBreakpoint(location).then(function ([response, bpClient]) {
+ // Check that the breakpoint has properly skipped forward one line.
+ Assert.equal(response.actualLocation.source.actor, source.actor);
+ Assert.equal(response.actualLocation.line, location.line + 1);
+ // Check that the same breakpoint actor was returned.
+ Assert.equal(response.actor, gBpActor);
+
+ if (++gCount < NUM_BREAKPOINTS) {
+ set_breakpoints(source, location);
+ return;
+ }
+
+ // After setting all the breakpoints, check that only one has effectively
+ // remained.
+ threadFront.once("paused", function (packet) {
+ // Check the return value.
+ Assert.equal(packet.frame.where.actor, source.actor);
+ Assert.equal(packet.frame.where.line, location.line + 1);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.why.actors[0], bpClient.actor);
+ // Check that the breakpoint worked.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+
+ threadFront.once("paused", function (packet) {
+ // We don't expect any more pauses after the breakpoint was hit once.
+ Assert.ok(false);
+ });
+ threadFront.resume().then(function () {
+ // Give any remaining breakpoints a chance to trigger.
+ do_timeout(1000, resolve);
+ });
+ });
+ // Continue until the breakpoint is hit.
+ threadFront.resume();
+ });
+ }
+ });
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-13.js b/devtools/server/tests/xpcshell/test_breakpoint-13.js
new file mode 100644
index 0000000000..2265f3449a
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-13.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that execution doesn't pause twice while stepping, when encountering
+ * either a breakpoint or a debugger statement.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ await threadFront.setBreakpoint(
+ { sourceUrl: source.url, line: 3, column: 6 },
+ {}
+ );
+
+ info("Check that the stepping worked.");
+ const packet1 = await stepIn(threadFront);
+ Assert.equal(packet1.frame.where.line, 6);
+ Assert.equal(packet1.why.type, "resumeLimit");
+
+ info("Entered the foo function call frame.");
+ const packet2 = await stepIn(threadFront);
+ Assert.equal(packet2.frame.where.line, 3);
+ Assert.equal(packet2.why.type, "resumeLimit");
+
+ info("Check that the breakpoint wasn't the reason for this pause");
+ const packet3 = await stepIn(threadFront);
+ Assert.equal(packet3.frame.where.line, 4);
+ Assert.equal(packet3.why.type, "resumeLimit");
+ Assert.equal(packet3.why.frameFinished.return.type, "undefined");
+
+ info("Check that the debugger statement wasn't the reason for this pause.");
+ const packet4 = await stepIn(threadFront);
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+ Assert.equal(packet4.frame.where.line, 7);
+ Assert.equal(packet4.why.type, "resumeLimit");
+
+ info("Check that the debugger statement wasn't the reason for this pause.");
+ const packet5 = await stepIn(threadFront);
+ Assert.equal(packet5.frame.where.line, 8);
+ Assert.equal(packet5.why.type, "resumeLimit");
+
+ info("Remove the breakpoint and finish.");
+ await stepIn(threadFront);
+ threadFront.removeBreakpoint({ sourceUrl: source.url, line: 3 });
+
+ await resume(threadFront);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ `
+ function foo() {
+ this.a = 1; // <-- breakpoint set here
+ }
+ debugger;
+ foo();
+ debugger;
+ var b = 2;
+ `,
+ debuggee,
+ "1.8",
+ "test_breakpoint-13.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-14.js b/devtools/server/tests/xpcshell/test_breakpoint-14.js
new file mode 100644
index 0000000000..835edb1385
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-14.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow */
+
+"use strict";
+
+/**
+ * Check that a breakpoint or a debugger statement cause execution to pause even
+ * in a stepped-over function.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, client, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = {
+ sourceUrl: source.url,
+ line: debuggee.line0 + 2,
+ };
+
+ //Pause at debugger statement.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 4);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ threadFront.setBreakpoint(location, {});
+
+ const testCallbacks = [
+ function (packet) {
+ // Check that the stepping worked.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 5);
+ Assert.equal(packet.why.type, "resumeLimit");
+ },
+ function (packet) {
+ // Reached the breakpoint.
+ Assert.equal(packet.frame.where.line, location.line);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.notEqual(packet.why.type, "resumeLimit");
+ },
+ function (packet) {
+ // The frame is about to be popped while stepping.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 3);
+ Assert.notEqual(packet.why.type, "breakpoint");
+ Assert.equal(packet.why.type, "resumeLimit");
+ Assert.equal(packet.why.frameFinished.return.type, "undefined");
+ },
+ function (packet) {
+ // Check that the debugger statement wasn't the reason for this pause.
+ Assert.equal(debuggee.a, 1);
+ Assert.equal(debuggee.b, undefined);
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 6);
+ Assert.notEqual(packet.why.type, "debuggerStatement");
+ Assert.equal(packet.why.type, "resumeLimit");
+ },
+ function (packet) {
+ // Check that the debugger statement wasn't the reason for this pause.
+ Assert.equal(packet.frame.where.line, debuggee.line0 + 7);
+ Assert.notEqual(packet.why.type, "debuggerStatement");
+ Assert.equal(packet.why.type, "resumeLimit");
+ },
+ ];
+
+ for (const callback of testCallbacks) {
+ const waiter = waitForPause(threadFront);
+ threadFront.stepOver();
+ const packet = await waiter;
+ callback(packet);
+ }
+
+ // Remove the breakpoint and finish.
+ threadFront.removeBreakpoint(location);
+
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox("var line0 = Error().lineNumber;\n" +
+ "function foo() {\n" + // line0 + 1
+ " this.a = 1;\n" + // line0 + 2 <-- Breakpoint is set here.
+ "}\n" + // line0 + 3
+ "debugger;\n" + // line0 + 4
+ "foo();\n" + // line0 + 5
+ "debugger;\n" + // line0 + 6
+ "var b = 2;\n", // line0 + 7
+ debuggee);
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-16.js b/devtools/server/tests/xpcshell/test_breakpoint-16.js
new file mode 100644
index 0000000000..a42306eee1
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-16.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that we can set breakpoints in columns, not just lines.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, client, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = {
+ sourceUrl: source.url,
+ line: debuggee.line0 + 1,
+ column: 55,
+ };
+
+ let timesBreakpointHit = 0;
+ threadFront.setBreakpoint(location, {});
+
+ while (timesBreakpointHit < 3) {
+ await resume(threadFront);
+ const packet = await waitForPause(threadFront);
+ await testAssertions(
+ packet,
+ debuggee,
+ source,
+ location,
+ timesBreakpointHit
+ );
+
+ timesBreakpointHit++;
+ }
+
+ threadFront.removeBreakpoint(location);
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "(function () { debugger; this.acc = 0; for (var i = 0; i < 3; i++) this.acc++; }());",
+ debuggee
+ );
+}
+
+async function testAssertions(
+ packet,
+ debuggee,
+ source,
+ location,
+ timesBreakpointHit
+) {
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.frame.where.actor, source.actor);
+ Assert.equal(packet.frame.where.line, location.line);
+ Assert.equal(packet.frame.where.column, location.column);
+
+ Assert.equal(debuggee.acc, timesBreakpointHit);
+ const environment = await packet.frame.getEnvironment();
+ Assert.equal(environment.bindings.variables.i.value, timesBreakpointHit);
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-17.js b/devtools/server/tests/xpcshell/test_breakpoint-17.js
new file mode 100644
index 0000000000..c52e6547ef
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-17.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow */
+
+"use strict";
+
+/**
+ * Test that when we add 2 breakpoints to the same line at different columns and
+ * then remove one of them, we don't remove them both.
+ */
+
+const code =
+ "(" +
+ function (global) {
+ global.foo = function () {
+ Math.abs(-1);
+ Math.log(0.5);
+ debugger;
+ };
+ debugger;
+ } +
+ "(this))";
+
+const firstLocation = {
+ line: 3,
+ column: 4,
+};
+
+const secondLocation = {
+ line: 3,
+ column: 18,
+};
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee }) => {
+ return new Promise(resolve => {
+ threadFront.on("paused", async packet => {
+ const [first, second] = await set_breakpoints(packet, threadFront);
+ test_different_actors(first, second);
+ await test_remove_one(first, second, threadFront, debuggee);
+ resolve();
+ });
+
+ Cu.evalInSandbox(code, debuggee, "1.8", "http://example.com/", 1);
+ });
+ })
+);
+
+async function set_breakpoints(packet, threadFront) {
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ return new Promise(resolve => {
+ let first, second;
+
+ source
+ .setBreakpoint(firstLocation)
+ .then(function ([{ actualLocation }, breakpointClient]) {
+ Assert.ok(!actualLocation, "Should not get an actualLocation");
+ first = breakpointClient;
+
+ source
+ .setBreakpoint(secondLocation)
+ .then(function ([{ actualLocation }, breakpointClient]) {
+ Assert.ok(!actualLocation, "Should not get an actualLocation");
+ second = breakpointClient;
+
+ resolve([first, second]);
+ });
+ });
+ });
+}
+
+function test_different_actors(first, second) {
+ Assert.notEqual(
+ first.actor,
+ second.actor,
+ "Each breakpoint should have a different actor"
+ );
+}
+
+function test_remove_one(first, second, threadFront, debuggee) {
+ return new Promise(resolve => {
+ first.remove(function ({ error }) {
+ Assert.ok(!error, "Should not get an error removing a breakpoint");
+
+ let hitSecond;
+ threadFront.on("paused", function _onPaused({ why, frame }) {
+ if (why.type == "breakpoint") {
+ hitSecond = true;
+ Assert.equal(
+ why.actors.length,
+ 1,
+ "Should only be paused because of one breakpoint actor"
+ );
+ Assert.equal(
+ why.actors[0],
+ second.actor,
+ "Should be paused because of the correct breakpoint actor"
+ );
+ Assert.equal(
+ frame.where.line,
+ secondLocation.line,
+ "Should be at the right line"
+ );
+ Assert.equal(
+ frame.where.column,
+ secondLocation.column,
+ "Should be at the right column"
+ );
+ threadFront.resume();
+ return;
+ }
+
+ if (why.type == "debuggerStatement") {
+ threadFront.off("paused", _onPaused);
+ Assert.ok(
+ hitSecond,
+ "We should still hit `second`, but not `first`."
+ );
+
+ resolve();
+ return;
+ }
+
+ Assert.ok(false, "Should never get here");
+ });
+
+ threadFront.resume().then(() => debuggee.foo());
+ });
+ });
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-18.js b/devtools/server/tests/xpcshell/test_breakpoint-18.js
new file mode 100644
index 0000000000..b2c86458d0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-18.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that we only break on offsets that are entry points for the line we are
+ * breaking on. Bug 907278.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, client, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = { sourceUrl: source.url, line: 3 };
+ threadFront.setBreakpoint(location, {});
+ await client.waitForRequestsToSettle();
+
+ debuggee.console = { log: x => void x };
+
+ await resume(threadFront);
+
+ const packet2 = await executeOnNextTickAndWaitForPause(
+ debuggee.test,
+ threadFront
+ );
+ Assert.equal(packet2.why.type, "breakpoint");
+
+ const packet3 = await resumeAndWaitForPause(threadFront);
+ testDbgStatement(packet3);
+
+ await resume(threadFront);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ Cu.evalInSandbox(
+ "debugger;\n" +
+ function test() {
+ console.log("foo bar");
+ debugger;
+ },
+ debuggee,
+ "1.8",
+ "http://example.com/",
+ 1
+ );
+}
+
+function testDbgStatement({ why }) {
+ // Should continue to the debugger statement.
+ Assert.equal(why.type, "debuggerStatement");
+ // Not break on another offset from the same line (that isn't an entry point
+ // to the line)
+ Assert.notEqual(why.type, "breakpoint");
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-19.js b/devtools/server/tests/xpcshell/test_breakpoint-19.js
new file mode 100644
index 0000000000..013acdfaf1
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-19.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Make sure that setting a breakpoint in a not-yet-existing script doesn't throw
+ * an error (see bug 897567). Also make sure that this breakpoint works.
+ */
+
+const URL = "test.js";
+
+function setUpCode(debuggee) {
+ /* eslint-disable mozilla/var-only-at-top-level, no-unused-vars */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "" + function test() { // 1
+ var a = 1; // 2
+ debugger; // 3
+ } + // 4
+ "\ndebugger;", // 5
+ debuggee,
+ "1.8",
+ URL
+ );
+ /* eslint-enable mozilla/var-only-at-top-level, no-unused-vars */
+}
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ setBreakpoint(threadFront, { sourceUrl: URL, line: 2 });
+
+ await executeOnNextTickAndWaitForPause(
+ () => setUpCode(debuggee),
+ threadFront
+ );
+ await resume(threadFront);
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ debuggee.test,
+ threadFront
+ );
+ equal(packet.why.type, "breakpoint");
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-20.js b/devtools/server/tests/xpcshell/test_breakpoint-20.js
new file mode 100644
index 0000000000..886d44164d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-20.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verify that when two of the "same" source are loaded concurrently (like e10s
+ * frame scripts), breakpoints get hit in scripts defined by all sources.
+ */
+
+var gDebuggee;
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ gDebuggee = debuggee;
+ await testBreakpoint(threadFront);
+ })
+);
+
+const testBreakpoint = async function (threadFront) {
+ evalSetupCode();
+
+ // Load the test source once.
+
+ evalTestCode();
+ equal(
+ gDebuggee.functions.length,
+ 1,
+ "The test code should have added a function."
+ );
+
+ // Set a breakpoint in the test source.
+
+ const source = await getSource(threadFront, "test.js");
+ setBreakpoint(threadFront, { sourceUrl: source.url, line: 3 });
+
+ // Load the test source again.
+
+ evalTestCode();
+ equal(
+ gDebuggee.functions.length,
+ 2,
+ "The test code should have added another function."
+ );
+
+ // Should hit our breakpoint in a script defined by the first instance of the
+ // test source.
+
+ const bpPause1 = await executeOnNextTickAndWaitForPause(
+ gDebuggee.functions[0],
+ threadFront
+ );
+ equal(
+ bpPause1.why.type,
+ "breakpoint",
+ "Should pause because of hitting our breakpoint (not debugger statement)."
+ );
+ const dbgStmtPause1 = await executeOnNextTickAndWaitForPause(
+ () => resume(threadFront),
+ threadFront
+ );
+ equal(
+ dbgStmtPause1.why.type,
+ "debuggerStatement",
+ "And we should hit the debugger statement after the pause."
+ );
+ await resume(threadFront);
+
+ // Should also hit our breakpoint in a script defined by the second instance
+ // of the test source.
+
+ const bpPause2 = await executeOnNextTickAndWaitForPause(
+ gDebuggee.functions[1],
+ threadFront
+ );
+ equal(
+ bpPause2.why.type,
+ "breakpoint",
+ "Should pause because of hitting our breakpoint (not debugger statement)."
+ );
+ const dbgStmtPause2 = await executeOnNextTickAndWaitForPause(
+ () => resume(threadFront),
+ threadFront
+ );
+ equal(
+ dbgStmtPause2.why.type,
+ "debuggerStatement",
+ "And we should hit the debugger statement after the pause."
+ );
+};
+
+function evalSetupCode() {
+ Cu.evalInSandbox("this.functions = [];", gDebuggee, "1.8", "setup.js", 1);
+}
+
+function evalTestCode() {
+ Cu.evalInSandbox(
+ ` // 1
+ this.functions.push(function () { // 2
+ var setBreakpointHere = 1; // 3
+ debugger; // 4
+ }); // 5
+ `,
+ gDebuggee,
+ "1.8",
+ "test.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-21.js b/devtools/server/tests/xpcshell/test_breakpoint-21.js
new file mode 100644
index 0000000000..da7a87f91c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-21.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Bug 1122064 - make sure that scripts introduced via onNewScripts
+ * properly populate the `ScriptStore` with all there nested child
+ * scripts, so you can set breakpoints on deeply nested scripts
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ // Populate the `ScriptStore` so that we only test that the script
+ // is added through `onNewScript`
+ await getSources(threadFront);
+
+ let packet = await executeOnNextTickAndWaitForPause(() => {
+ evalCode(debuggee);
+ }, threadFront);
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+ const location = {
+ sourceUrl: source.url,
+ line: debuggee.line0 + 8,
+ };
+
+ setBreakpoint(threadFront, location);
+
+ await resume(threadFront);
+ packet = await waitForPause(threadFront);
+ Assert.equal(packet.why.type, "breakpoint");
+ Assert.equal(packet.frame.where.actor, source.actor);
+ Assert.equal(packet.frame.where.line, location.line);
+
+ await resume(threadFront);
+ })
+);
+
+function evalCode(debuggee) {
+ // Start a new script
+ /* eslint-disable mozilla/var-only-at-top-level, max-nested-callbacks, no-unused-vars */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n(" + function () {
+ debugger;
+ var a = (function () {
+ return (function () {
+ return (function () {
+ return (function () {
+ return (function () {
+ var x = 10; // This line gets a breakpoint
+ return 1;
+ })();
+ })();
+ })();
+ })();
+ })();
+ } + ")()",
+ debuggee
+ );
+ /* eslint-enable mozilla/var-only-at-top-level, max-nested-callbacks, no-unused-vars */
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-22.js b/devtools/server/tests/xpcshell/test_breakpoint-22.js
new file mode 100644
index 0000000000..067dfa3fa2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-22.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Bug 1333219 - make that setBreakpoint fails when script is not found
+ * at the specified line.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ // Populate the `ScriptStore` so that we only test that the script
+ // is added through `onNewScript`
+ await getSources(threadFront);
+
+ const packet = await executeOnNextTickAndWaitForPause(() => {
+ evalCode(debuggee);
+ }, threadFront);
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+
+ const location = {
+ line: debuggee.line0 + 2,
+ };
+
+ const [res] = await setBreakpoint(source, location);
+ ok(!res.error);
+
+ const location2 = {
+ line: debuggee.line0 + 7,
+ };
+
+ await source.setBreakpoint(location2).then(
+ () => {
+ do_throw("no code shall not be found the specified line or below it");
+ },
+ reason => {
+ Assert.equal(reason.error, "noCodeAtLineColumn");
+ ok(reason.message);
+ }
+ );
+
+ await resume(threadFront);
+ })
+);
+
+function evalCode(debuggee) {
+ // Start a new script
+ Cu.evalInSandbox(
+ `
+var line0 = Error().lineNumber;
+function some_function() {
+ // breakpoint is valid here -- it slides one line below (line0 + 2)
+}
+debugger;
+// no breakpoint is allowed after the EOF (line0 + 6)
+`,
+ debuggee
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-23.js b/devtools/server/tests/xpcshell/test_breakpoint-23.js
new file mode 100644
index 0000000000..8f07190ea9
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-23.js
@@ -0,0 +1,35 @@
+/* eslint-disable max-nested-callbacks */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Bug 1552453 - Verify that breakpoints are hit for evaluated
+ * scripts that contain a source url pragma.
+ */
+add_task(
+ threadFrontTest(async ({ commands, threadFront }) => {
+ await threadFront.setBreakpoint(
+ { sourceUrl: "http://example.com/code.js", line: 2, column: 1 },
+ {}
+ );
+
+ info("Create a new script with the displayUrl code.js");
+ const onNewSource = waitForEvent(threadFront, "newSource");
+ await commands.scriptCommand.execute(
+ "function f() {\n return 5; \n}\n//# sourceURL=http://example.com/code.js"
+ );
+ const sourcePacket = await onNewSource;
+
+ equal(sourcePacket.source.url, "http://example.com/code.js");
+
+ info("Evaluate f() and pause at line 2");
+ const onExecutionDone = commands.scriptCommand.execute("f()");
+ const pausedPacket = await waitForPause(threadFront);
+ equal(pausedPacket.why.type, "breakpoint");
+ equal(pausedPacket.frame.where.line, 2);
+ resume(threadFront);
+ await onExecutionDone;
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-24.js b/devtools/server/tests/xpcshell/test_breakpoint-24.js
new file mode 100644
index 0000000000..a240a237f0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-24.js
@@ -0,0 +1,239 @@
+/* eslint-disable max-nested-callbacks */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Bug 1441183 - Verify that the debugger advances to a new location
+ * when encountering debugger statements and brakpoints
+ *
+ * Bug 1613165 - Verify that debugger statement is not disabled by
+ * adding/removing a breakpoint
+ */
+add_task(
+ threadFrontTest(async props => {
+ await testDebuggerStatements(props);
+ await testBreakpoints(props);
+ await testBreakpointsAndDebuggerStatements(props);
+ await testLoops(props);
+ await testRemovingBreakpoint(props);
+ await testAddingBreakpoint(props);
+ })
+);
+
+// Ensure that we advance to the next line when we
+// step to a debugger statement and resume.
+async function testDebuggerStatements({ commands, threadFront }) {
+ commands.scriptCommand.execute(`function foo(stop) {
+ debugger;
+ debugger;
+ debugger;
+ }
+ foo();
+ //# sourceURL=http://example.com/code.js`);
+
+ await performActions(threadFront, [
+ [
+ "paused at first debugger statement",
+ { line: 2, type: "debuggerStatement" },
+ "stepOver",
+ ],
+ [
+ "paused at the second debugger statement",
+ { line: 3, type: "resumeLimit" },
+ "resume",
+ ],
+ [
+ "paused at the third debugger statement",
+ { line: 4, type: "debuggerStatement" },
+ "resume",
+ ],
+ ]);
+}
+
+// Ensure that we advance to the next line when we hit a breakpoint
+// on a line with a debugger statement and resume.
+async function testBreakpointsAndDebuggerStatements({ commands, threadFront }) {
+ commands.scriptCommand.execute(`function foo(stop) {
+ debugger;
+ debugger;
+ debugger;
+ }
+ foo();
+ //# sourceURL=http://example.com/testBreakpointsAndDebuggerStatements.js`);
+
+ threadFront.setBreakpoint(
+ {
+ sourceUrl: "http://example.com/testBreakpointsAndDebuggerStatements.js",
+ line: 3,
+ },
+ {}
+ );
+
+ await performActions(threadFront, [
+ [
+ "paused at first debugger statement",
+ { line: 2, type: "debuggerStatement" },
+ "resume",
+ ],
+ [
+ "paused at the breakpoint at the second debugger statement",
+ { line: 3, type: "breakpoint" },
+ "resume",
+ ],
+ [
+ "pause at the third debugger statement",
+ { line: 4, type: "debuggerStatement" },
+ "resume",
+ ],
+ ]);
+}
+
+// Ensure that we advance to the next line when we step to
+// a line with a breakpoint and resume.
+async function testBreakpoints({ commands, threadFront }) {
+ commands.scriptCommand.execute(`function foo(stop) {
+ debugger;
+ a();
+ debugger;
+ }
+ function a() {}
+ foo();
+ //# sourceURL=http://example.com/testBreakpoints.js`);
+
+ threadFront.setBreakpoint(
+ { sourceUrl: "http://example.com/testBreakpoints.js", line: 3, column: 6 },
+ {}
+ );
+
+ await performActions(threadFront, [
+ [
+ "paused at first debugger statement",
+ { line: 2, type: "debuggerStatement" },
+ "stepOver",
+ ],
+ ["paused at a()", { line: 3, type: "resumeLimit" }, "resume"],
+ [
+ "pause at the second debugger satement",
+ { line: 4, type: "debuggerStatement" },
+ "resume",
+ ],
+ ]);
+}
+
+// Ensure that we advance to the next line when we step to
+// a line with a breakpoint and resume.
+async function testLoops({ commands, threadFront }) {
+ commands.scriptCommand.execute(`function foo(stop) {
+ let i = 0;
+ debugger;
+ while (i++ < 2) {
+ debugger;
+ }
+ debugger;
+ }
+ foo();
+ //# sourceURL=http://example.com/testLoops.js`);
+
+ await performActions(threadFront, [
+ [
+ "paused at first debugger statement",
+ { line: 3, type: "debuggerStatement" },
+ "resume",
+ ],
+ [
+ "pause at the second debugger satement",
+ { line: 5, type: "debuggerStatement" },
+ "resume",
+ ],
+ [
+ "pause at the second debugger satement (2nd time)",
+ { line: 5, type: "debuggerStatement" },
+ "resume",
+ ],
+ [
+ "pause at the third debugger satement",
+ { line: 7, type: "debuggerStatement" },
+ "resume",
+ ],
+ ]);
+}
+
+// Bug 1613165 - ensure that if you pause on a breakpoint on a line with
+// debugger statement, remove the breakpoint, and try to pause on the
+// debugger statement before pausing anywhere else, debugger pauses instead of
+// skipping debugger statement.
+async function testRemovingBreakpoint({ commands, threadFront }) {
+ commands.scriptCommand.execute(`function foo(stop) {
+ debugger;
+ }
+ foo();
+ foo();
+ //# sourceURL=http://example.com/testRemovingBreakpoint.js`);
+
+ const location = {
+ sourceUrl: "http://example.com/testRemovingBreakpoint.js",
+ line: 2,
+ column: 6,
+ };
+
+ threadFront.setBreakpoint(location, {});
+
+ info("paused at the breakpoint at the first debugger statement");
+ const packet = await waitForEvent(threadFront, "paused");
+ Assert.equal(packet.frame.where.line, 2);
+ Assert.equal(packet.why.type, "breakpoint");
+ threadFront.removeBreakpoint(location);
+
+ info("paused at the first debugger statement");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 2);
+ Assert.equal(packet2.why.type, "debuggerStatement");
+ await threadFront.resume();
+}
+
+// Bug 1613165 - ensure if you pause on a debugger statement, add a
+// breakpoint on the same line, and try to pause on the breakpoint
+// before pausing anywhere else, debugger pauses on that line instead of
+// skipping breakpoint.
+async function testAddingBreakpoint({ commands, threadFront }) {
+ commands.scriptCommand.execute(`function foo(stop) {
+ debugger;
+ }
+ foo();
+ foo();
+ //# sourceURL=http://example.com/testAddingBreakpoint.js`);
+
+ const location = {
+ sourceUrl: "http://example.com/testAddingBreakpoint.js",
+ line: 2,
+ column: 6,
+ };
+
+ info("paused at the first debugger statement");
+ const packet = await waitForEvent(threadFront, "paused");
+ Assert.equal(packet.frame.where.line, 2);
+ Assert.equal(packet.why.type, "debuggerStatement");
+ threadFront.setBreakpoint(location, {});
+
+ info("paused at the breakpoint at the first debugger statement");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 2);
+ Assert.equal(packet2.why.type, "breakpoint");
+ await threadFront.resume();
+}
+
+async function performActions(threadFront, actions) {
+ for (const action of actions) {
+ await performAction(threadFront, action);
+ }
+}
+
+async function performAction(threadFront, [description, result, action]) {
+ info(description);
+ const packet = await waitForEvent(threadFront, "paused");
+ Assert.equal(packet.frame.where.line, result.line);
+ Assert.equal(packet.why.type, result.type);
+ await threadFront[action]();
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-25.js b/devtools/server/tests/xpcshell/test_breakpoint-25.js
new file mode 100644
index 0000000000..f155234c96
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-25.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Ensure that the debugger resume page execution when the connection drops
+ * and when the target is detached.
+ */
+
+add_task(
+ threadFrontTest(({ threadFront, debuggee, targetFront }) => {
+ return new Promise(resolve => {
+ (async () => {
+ await executeOnNextTickAndWaitForPause(evalCode, threadFront);
+
+ ok(true, "The page is paused");
+ ok(!debuggee.foo, "foo is still false after we hit the breakpoint");
+
+ await targetFront.detach();
+
+ // Closing the connection will force the thread actor to resume page
+ // execution
+ ok(debuggee.foo, "foo is true after target's detach request");
+
+ resolve();
+ })();
+
+ function evalCode() {
+ /* eslint-disable */
+ Cu.evalInSandbox("var foo = false;\n", debuggee);
+ /* eslint-enable */
+ ok(!debuggee.foo, "foo is false at startup");
+
+ /* eslint-disable */
+ Cu.evalInSandbox("debugger;\n" + "foo = true;\n", debuggee);
+ /* eslint-enable */
+ }
+ });
+ })
+);
+
+add_task(
+ threadFrontTest(({ threadFront, client, debuggee }) => {
+ return new Promise(resolve => {
+ (async () => {
+ await executeOnNextTickAndWaitForPause(evalCode, threadFront);
+
+ ok(true, "The page is paused");
+ ok(!debuggee.foo, "foo is still false after we hit the breakpoint");
+
+ await client.close();
+
+ // `close` will force the destruction of the thread actor, which,
+ // will resume the page execution. But all of that seems to be
+ // synchronous and we have to spin the event loop in order to ensure
+ // having the content javascript to execute the resumed code.
+ await new Promise(executeSoon);
+
+ // Closing the connection will force the thread actor to resume page
+ // execution
+ ok(debuggee.foo, "foo is true after client close");
+ executeSoon(resolve);
+ dump("resolved\n");
+ })();
+
+ function evalCode() {
+ /* eslint-disable */
+ Cu.evalInSandbox("var foo = false;\n", debuggee);
+ /* eslint-enable */
+ ok(!debuggee.foo, "foo is false at startup");
+
+ /* eslint-disable */
+ Cu.evalInSandbox("debugger;\n" + "foo = true;\n", debuggee);
+ /* eslint-enable */
+ }
+ });
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-26.js b/devtools/server/tests/xpcshell/test_breakpoint-26.js
new file mode 100644
index 0000000000..8624171252
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-26.js
@@ -0,0 +1,63 @@
+/* eslint-disable max-nested-callbacks */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Bug 925269 - Verify that debugger statements are skipped
+ * if there is a falsey conditional breakpoint at the same location.
+ */
+add_task(
+ threadFrontTest(async props => {
+ await testBreakpointsAndDebuggerStatements(props);
+ })
+);
+
+async function testBreakpointsAndDebuggerStatements({ commands, threadFront }) {
+ commands.scriptCommand.execute(
+ `function foo(stop) {
+ debugger;
+ debugger;
+ debugger;
+ }
+ foo();
+ //# sourceURL=http://example.com/testBreakpointsAndDebuggerStatements.js`
+ );
+
+ threadFront.setBreakpoint(
+ {
+ sourceUrl: "http://example.com/testBreakpointsAndDebuggerStatements.js",
+ line: 3,
+ column: 6,
+ },
+ { condition: "false" }
+ );
+
+ await performActions(threadFront, [
+ [
+ "paused at first debugger statement",
+ { line: 2, type: "debuggerStatement" },
+ "resume",
+ ],
+ [
+ "pause at the third debugger statement",
+ { line: 4, type: "debuggerStatement" },
+ "resume",
+ ],
+ ]);
+}
+
+async function performActions(threadFront, actions) {
+ for (const action of actions) {
+ await performAction(threadFront, action);
+ }
+}
+
+async function performAction(threadFront, [description, result, action]) {
+ info(description);
+ const packet = await waitForEvent(threadFront, "paused");
+ Assert.equal(packet.frame.where.line, result.line);
+ Assert.equal(packet.why.type, result.type);
+ await threadFront[action]();
+}
diff --git a/devtools/server/tests/xpcshell/test_breakpoint-actor-map.js b/devtools/server/tests/xpcshell/test_breakpoint-actor-map.js
new file mode 100644
index 0000000000..e45096095e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_breakpoint-actor-map.js
@@ -0,0 +1,257 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the functionality of the BreakpointActorMap object.
+
+const {
+ BreakpointActorMap,
+} = require("resource://devtools/server/actors/utils/breakpoint-actor-map.js");
+
+function run_test() {
+ test_get_actor();
+ test_set_actor();
+ test_delete_actor();
+ test_find_actors();
+ test_duplicate_actors();
+}
+
+function test_get_actor() {
+ const bpStore = new BreakpointActorMap();
+ const location = {
+ generatedSourceActor: { actor: "actor1" },
+ generatedLine: 3,
+ };
+ const columnLocation = {
+ generatedSourceActor: { actor: "actor2" },
+ generatedLine: 5,
+ generatedColumn: 15,
+ };
+
+ // Shouldn't have breakpoint
+ Assert.equal(
+ null,
+ bpStore.getActor(location),
+ "Breakpoint not added and shouldn't exist."
+ );
+
+ bpStore.setActor(location, {});
+ Assert.ok(
+ !!bpStore.getActor(location),
+ "Breakpoint added but not found in Breakpoint Store."
+ );
+
+ bpStore.deleteActor(location);
+ Assert.equal(
+ null,
+ bpStore.getActor(location),
+ "Breakpoint removed but still exists."
+ );
+
+ // Same checks for breakpoint with a column
+ Assert.equal(
+ null,
+ bpStore.getActor(columnLocation),
+ "Breakpoint with column not added and shouldn't exist."
+ );
+
+ bpStore.setActor(columnLocation, {});
+ Assert.ok(
+ !!bpStore.getActor(columnLocation),
+ "Breakpoint with column added but not found in Breakpoint Store."
+ );
+
+ bpStore.deleteActor(columnLocation);
+ Assert.equal(
+ null,
+ bpStore.getActor(columnLocation),
+ "Breakpoint with column removed but still exists in Breakpoint Store."
+ );
+}
+
+function test_set_actor() {
+ // Breakpoint with column
+ const bpStore = new BreakpointActorMap();
+ let location = {
+ generatedSourceActor: { actor: "actor1" },
+ generatedLine: 10,
+ generatedColumn: 9,
+ };
+ bpStore.setActor(location, {});
+ Assert.ok(
+ !!bpStore.getActor(location),
+ "We should have the column breakpoint we just added"
+ );
+
+ // Breakpoint without column (whole line breakpoint)
+ location = {
+ generatedSourceActor: { actor: "actor2" },
+ generatedLine: 103,
+ };
+ bpStore.setActor(location, {});
+ Assert.ok(
+ !!bpStore.getActor(location),
+ "We should have the whole line breakpoint we just added"
+ );
+}
+
+function test_delete_actor() {
+ // Breakpoint with column
+ const bpStore = new BreakpointActorMap();
+ let location = {
+ generatedSourceActor: { actor: "actor1" },
+ generatedLine: 10,
+ generatedColumn: 9,
+ };
+ bpStore.setActor(location, {});
+ bpStore.deleteActor(location);
+ Assert.equal(
+ bpStore.getActor(location),
+ null,
+ "We should not have the column breakpoint anymore"
+ );
+
+ // Breakpoint without column (whole line breakpoint)
+ location = {
+ generatedSourceActor: { actor: "actor2" },
+ generatedLine: 103,
+ };
+ bpStore.setActor(location, {});
+ bpStore.deleteActor(location);
+ Assert.equal(
+ bpStore.getActor(location),
+ null,
+ "We should not have the whole line breakpoint anymore"
+ );
+}
+
+function test_find_actors() {
+ const bps = [
+ { generatedSourceActor: { actor: "actor1" }, generatedLine: 10 },
+ {
+ generatedSourceActor: { actor: "actor1" },
+ generatedLine: 10,
+ generatedColumn: 3,
+ },
+ {
+ generatedSourceActor: { actor: "actor1" },
+ generatedLine: 10,
+ generatedColumn: 10,
+ },
+ {
+ generatedSourceActor: { actor: "actor1" },
+ generatedLine: 23,
+ generatedColumn: 89,
+ },
+ {
+ generatedSourceActor: { actor: "actor2" },
+ generatedLine: 10,
+ generatedColumn: 1,
+ },
+ {
+ generatedSourceActor: { actor: "actor2" },
+ generatedLine: 20,
+ generatedColumn: 5,
+ },
+ {
+ generatedSourceActor: { actor: "actor2" },
+ generatedLine: 30,
+ generatedColumn: 34,
+ },
+ {
+ generatedSourceActor: { actor: "actor2" },
+ generatedLine: 40,
+ generatedColumn: 56,
+ },
+ ];
+
+ const bpStore = new BreakpointActorMap();
+
+ for (const bp of bps) {
+ bpStore.setActor(bp, bp);
+ }
+
+ // All breakpoints
+
+ let bpSet = new Set(bps);
+ for (const bp of bpStore.findActors()) {
+ bpSet.delete(bp);
+ }
+ Assert.equal(bpSet.size, 0, "Should be able to iterate over all breakpoints");
+
+ // Breakpoints by URL
+
+ bpSet = new Set(
+ bps.filter(bp => {
+ return bp.generatedSourceActor.actorID === "actor1";
+ })
+ );
+ for (const bp of bpStore.findActors({
+ generatedSourceActor: { actorID: "actor1" },
+ })) {
+ bpSet.delete(bp);
+ }
+ Assert.equal(bpSet.size, 0, "Should be able to filter the iteration by url");
+
+ // Breakpoints by URL and line
+
+ bpSet = new Set(
+ bps.filter(bp => {
+ return (
+ bp.generatedSourceActor.actorID === "actor1" && bp.generatedLine === 10
+ );
+ })
+ );
+ let first = true;
+ for (const bp of bpStore.findActors({
+ generatedSourceActor: { actorID: "actor1" },
+ generatedLine: 10,
+ })) {
+ if (first) {
+ Assert.equal(
+ bp.generatedColumn,
+ undefined,
+ "Should always get the whole line breakpoint first"
+ );
+ first = false;
+ } else {
+ Assert.notEqual(
+ bp.generatedColumn,
+ undefined,
+ "Should not get the whole line breakpoint any time other than first."
+ );
+ }
+ bpSet.delete(bp);
+ }
+ Assert.equal(
+ bpSet.size,
+ 0,
+ "Should be able to filter the iteration by url and line"
+ );
+}
+
+function test_duplicate_actors() {
+ const bpStore = new BreakpointActorMap();
+
+ // Breakpoint with column
+ let location = {
+ generatedSourceActor: { actorID: "foo-actor" },
+ generatedLine: 10,
+ generatedColumn: 9,
+ };
+ bpStore.setActor(location, {});
+ bpStore.setActor(location, {});
+ Assert.equal(bpStore.size, 1, "We should have only 1 column breakpoint");
+ bpStore.deleteActor(location);
+
+ // Breakpoint without column (whole line breakpoint)
+ location = {
+ generatedSourceActor: { actorID: "foo-actor" },
+ generatedLine: 15,
+ };
+ bpStore.setActor(location, {});
+ bpStore.setActor(location, {});
+ Assert.equal(bpStore.size, 1, "We should have only 1 whole line breakpoint");
+ bpStore.deleteActor(location);
+}
diff --git a/devtools/server/tests/xpcshell/test_client_request.js b/devtools/server/tests/xpcshell/test_client_request.js
new file mode 100644
index 0000000000..837bee5047
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_client_request.js
@@ -0,0 +1,220 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the DevToolsClient.request API.
+
+var gClient, gActorId;
+
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+
+class TestActor extends Actor {
+ constructor(conn) {
+ super(conn, { typeName: "test", methods: [] });
+
+ this.requestTypes = {
+ hello: this.hello,
+ error: this.error,
+ };
+ }
+
+ hello() {
+ return { hello: "world" };
+ }
+
+ error() {
+ return { error: "code", message: "human message" };
+ }
+}
+
+function run_test() {
+ ActorRegistry.addGlobalActor(
+ {
+ constructorName: "TestActor",
+ constructorFun: TestActor,
+ },
+ "test"
+ );
+
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ add_test(init);
+ add_test(test_client_request_promise);
+ add_test(test_client_request_promise_error);
+ add_test(test_client_request_event_emitter);
+ add_test(test_close_client_while_sending_requests);
+ add_test(test_client_request_after_close);
+ run_next_test();
+}
+
+function init() {
+ gClient = new DevToolsClient(DevToolsServer.connectPipe());
+ gClient
+ .connect()
+ .then(() => gClient.mainRoot.rootForm)
+ .then(response => {
+ gActorId = response.test;
+ run_next_test();
+ });
+}
+
+function checkStack(expectedName) {
+ let stack = Components.stack;
+ while (stack) {
+ info(stack.name);
+ if (stack.name == expectedName) {
+ // Reached back to outer function before request
+ ok(true, "Complete stack");
+ return;
+ }
+ stack = stack.asyncCaller || stack.caller;
+ }
+ ok(false, "Incomplete stack");
+}
+
+function test_client_request_promise() {
+ // Test that DevToolsClient.request returns a promise that resolves on response
+ const request = gClient.request({
+ to: gActorId,
+ type: "hello",
+ });
+
+ request.then(response => {
+ Assert.equal(response.from, gActorId);
+ Assert.equal(response.hello, "world");
+ checkStack("test_client_request_promise/<");
+ run_next_test();
+ });
+}
+
+function test_client_request_promise_error() {
+ // Test that DevToolsClient.request returns a promise that reject when server
+ // returns an explicit error message
+ const request = gClient.request({
+ to: gActorId,
+ type: "error",
+ });
+
+ request.then(
+ () => {
+ do_throw("Promise shouldn't be resolved on error");
+ },
+ response => {
+ Assert.equal(response.from, gActorId);
+ Assert.equal(response.error, "code");
+ Assert.equal(response.message, "human message");
+ checkStack("test_client_request_promise_error/<");
+ run_next_test();
+ }
+ );
+}
+
+function test_client_request_event_emitter() {
+ // Test that DevToolsClient.request returns also an EventEmitter object
+ const request = gClient.request({
+ to: gActorId,
+ type: "hello",
+ });
+ request.on("json-reply", reply => {
+ Assert.equal(reply.from, gActorId);
+ Assert.equal(reply.hello, "world");
+ checkStack("test_client_request_event_emitter");
+ run_next_test();
+ });
+}
+
+function test_close_client_while_sending_requests() {
+ // First send a first request that will be "active"
+ // while the connection is closed.
+ // i.e. will be sent but no response received yet.
+ const activeRequest = gClient.request({
+ to: gActorId,
+ type: "hello",
+ });
+
+ // Pile up a second one that will be "pending".
+ // i.e. won't event be sent.
+ const pendingRequest = gClient.request({
+ to: gActorId,
+ type: "hello",
+ });
+
+ const expectReply = new Promise(resolve => {
+ gClient.expectReply("root", function (response) {
+ Assert.equal(response.error, "connectionClosed");
+ Assert.equal(
+ response.message,
+ "server side packet can't be received as the connection just closed."
+ );
+ resolve();
+ });
+ });
+
+ gClient.close().then(() => {
+ activeRequest
+ .then(
+ () => {
+ ok(
+ false,
+ "First request unexpectedly succeed while closing the connection"
+ );
+ },
+ response => {
+ Assert.equal(response.error, "connectionClosed");
+ Assert.equal(
+ response.message,
+ "'hello' active request packet to '" +
+ gActorId +
+ "' can't be sent as the connection just closed."
+ );
+ }
+ )
+ .then(() => pendingRequest)
+ .then(
+ () => {
+ ok(
+ false,
+ "Second request unexpectedly succeed while closing the connection"
+ );
+ },
+ response => {
+ Assert.equal(response.error, "connectionClosed");
+ Assert.equal(
+ response.message,
+ "'hello' pending request packet to '" +
+ gActorId +
+ "' can't be sent as the connection just closed."
+ );
+ }
+ )
+ .then(() => expectReply)
+ .then(run_next_test);
+ });
+}
+
+function test_client_request_after_close() {
+ // Test that DevToolsClient.request fails after we called client.close()
+ // (with promise API)
+ const request = gClient.request({
+ to: gActorId,
+ type: "hello",
+ });
+
+ request.then(
+ response => {
+ ok(false, "Request succeed even after client.close");
+ },
+ response => {
+ ok(true, "Request failed after client.close");
+ Assert.equal(response.error, "connectionClosed");
+ ok(
+ response.message.match(
+ /'hello' request packet to '.*' can't be sent as the connection is closed./
+ )
+ );
+ run_next_test();
+ }
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_conditional_breakpoint-01.js b/devtools/server/tests/xpcshell/test_conditional_breakpoint-01.js
new file mode 100644
index 0000000000..8f2e58f651
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_conditional_breakpoint-01.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check conditional breakpoint when condition evaluates to true.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ let hitBreakpoint = false;
+
+ const packet1 = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet1.frame.where.actor);
+ const location = { sourceUrl: source.url, line: 3 };
+ threadFront.setBreakpoint(location, { condition: "a === 1" });
+
+ // Continue until the breakpoint is hit.
+ const packet2 = await resumeAndWaitForPause(threadFront);
+
+ Assert.equal(hitBreakpoint, false);
+ hitBreakpoint = true;
+
+ // Check the return value.
+ Assert.equal(packet2.why.type, "breakpoint");
+ Assert.equal(packet2.frame.where.line, 3);
+
+ // Remove the breakpoint.
+ await threadFront.removeBreakpoint(location);
+
+ await threadFront.resume();
+
+ Assert.equal(hitBreakpoint, true);
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ "debugger;\n" + // line 1
+ "var a = 1;\n" + // line 2
+ "var b = 2;\n", // line 3
+ debuggee,
+ "1.8",
+ "test.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_conditional_breakpoint-02.js b/devtools/server/tests/xpcshell/test_conditional_breakpoint-02.js
new file mode 100644
index 0000000000..18742c4048
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_conditional_breakpoint-02.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check conditional breakpoint when condition evaluates to false.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet1 = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet1.frame.where.actor);
+ const location1 = { sourceUrl: source.url, line: 3 };
+ threadFront.setBreakpoint(location1, { condition: "a === 2" });
+
+ const location2 = { sourceUrl: source.url, line: 4 };
+ threadFront.setBreakpoint(location2, { condition: "a === 1" });
+
+ // Continue until the breakpoint is hit.
+ const packet2 = await resumeAndWaitForPause(threadFront);
+
+ // Check the return value.
+ Assert.equal(packet2.why.type, "breakpoint");
+ Assert.equal(packet2.frame.where.line, 4);
+
+ // Remove the breakpoint.
+ await threadFront.removeBreakpoint(location2);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ "debugger;\n" + // line 1
+ "var a = 1;\n" + // line 2
+ "var b = 2;\n" + // line 3
+ "b++;" + // line 4
+ "debugger;", // line 5
+ debuggee,
+ "1.8",
+ "test.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_conditional_breakpoint-03.js b/devtools/server/tests/xpcshell/test_conditional_breakpoint-03.js
new file mode 100644
index 0000000000..94ac46c307
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_conditional_breakpoint-03.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * If pauseOnExceptions is checked, when condition throws,
+ * make sure conditional breakpoint pauses but doesn't trigger an exception breakpoint.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, commands }) => {
+ const packet1 = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet1.frame.where.actor);
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: false,
+ });
+ const location = { sourceUrl: source.url, line: 3 };
+ threadFront.setBreakpoint(location, { condition: "throw new Error()" });
+
+ // Continue until the breakpoint is hit.
+ const packet2 = await resumeAndWaitForPause(threadFront);
+
+ // Check the return value.
+ Assert.equal(packet2.why.type, "breakpointConditionThrown");
+ Assert.equal(packet2.frame.where.line, 3);
+
+ // Remove the breakpoint.
+ await threadFront.removeBreakpoint(location);
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ "debugger;\n" + // line 1
+ "var a = 1;\n" + // line 2
+ "var b = 2;\n", // line 3
+ debuggee,
+ "1.8",
+ "test.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_conditional_breakpoint-04.js b/devtools/server/tests/xpcshell/test_conditional_breakpoint-04.js
new file mode 100644
index 0000000000..b270b92974
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_conditional_breakpoint-04.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Confirm that conditional breakpoint are triggered in case of exceptions,
+ * even when pause-on-exceptions is disabled.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, commands }) => {
+ await threadFront.setBreakpoint(
+ { sourceUrl: "conditional_breakpoint-04.js", line: 3 },
+ { condition: "throw new Error()" }
+ );
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ Assert.equal(packet.frame.where.line, 1);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ const pausedPacket = await resumeAndWaitForPause(threadFront);
+ Assert.equal(pausedPacket.frame.where.line, 3);
+ Assert.equal(pausedPacket.why.type, "breakpointConditionThrown");
+
+ const secondPausedPacket = await resumeAndWaitForPause(threadFront);
+ Assert.equal(secondPausedPacket.frame.where.line, 4);
+ Assert.equal(secondPausedPacket.why.type, "debuggerStatement");
+
+ // Remove the breakpoint.
+ await threadFront.removeBreakpoint({
+ sourceUrl: "conditional_breakpoint-04.js",
+ line: 3,
+ });
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ `debugger;
+ var a = 1;
+ var b = 2;
+ debugger;`,
+ debuggee,
+ "1.8",
+ "conditional_breakpoint-04.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_connection_closes_all_pools.js b/devtools/server/tests/xpcshell/test_connection_closes_all_pools.js
new file mode 100644
index 0000000000..d69291485d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_connection_closes_all_pools.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { Pool } = require("resource://devtools/shared/protocol/Pool.js");
+const {
+ DevToolsServerConnection,
+} = require("resource://devtools/server/devtools-server-connection.js");
+const {
+ LocalDebuggerTransport,
+} = require("resource://devtools/shared/transport/local-transport.js");
+
+// Helper class to assert how many times a Pool was destroyed
+class FakeActor extends Pool {
+ constructor(...args) {
+ super(...args);
+ this.destroyedCount = 0;
+ }
+
+ destroy() {
+ this.destroyedCount++;
+ super.destroy();
+ }
+}
+
+add_task(async function () {
+ const transport = new LocalDebuggerTransport();
+ const conn = new DevToolsServerConnection("prefix", transport);
+
+ // Setup a flat pool hierarchy with multiple pools:
+ //
+ // - pool1
+ // |
+ // \- actor1
+ //
+ // - pool2
+ // |
+ // |- actor2a
+ // |
+ // \- actor2b
+ //
+ // From the point of view of the DevToolsServerConnection, the only pools
+ // registered in _extraPools should be pool1 and pool2. Even though actor1,
+ // actor2a and actor2b extend Pool, they don't manage other pools.
+ const actor1 = new FakeActor(conn);
+ const pool1 = new Pool(conn, "pool-1");
+ pool1.manage(actor1);
+
+ const actor2a = new FakeActor(conn);
+ const actor2b = new FakeActor(conn);
+ const pool2 = new Pool(conn, "pool-2");
+ pool2.manage(actor2a);
+ pool2.manage(actor2b);
+
+ ok(!!actor1.actorID, "actor1 has a valid actorID");
+ ok(!!actor2a.actorID, "actor2a has a valid actorID");
+ ok(!!actor2b.actorID, "actor2b has a valid actorID");
+
+ conn.close();
+
+ equal(actor1.destroyedCount, 1, "actor1 was successfully destroyed");
+ equal(actor2a.destroyedCount, 1, "actor2 was successfully destroyed");
+ equal(actor2b.destroyedCount, 1, "actor2 was successfully destroyed");
+});
+
+add_task(async function () {
+ const transport = new LocalDebuggerTransport();
+ const conn = new DevToolsServerConnection("prefix", transport);
+
+ // Setup a nested pool hierarchy:
+ //
+ // - pool
+ // |
+ // \- parentActor
+ // |
+ // \- childActor
+ //
+ // Since parentActor is also a Pool from the point of view of the
+ // DevToolsServerConnection, it will attempt to destroy it when looping on
+ // this._extraPools. But since `parentActor` is also a direct child of `pool`,
+ // it has already been destroyed by the Pool destroy() mechanism.
+ //
+ // Here we check that we don't call destroy() too many times on a single Pool.
+ // Even though Pool::destroy() is stable when called multiple times, we can't
+ // guarantee the same for classes inheriting Pool.
+ const childActor = new FakeActor(conn);
+ const parentActor = new FakeActor(conn);
+ const pool = new Pool(conn, "pool");
+ pool.manage(parentActor);
+ parentActor.manage(childActor);
+
+ ok(!!parentActor.actorID, "customActor has a valid actorID");
+ ok(!!childActor.actorID, "childActor has a valid actorID");
+
+ conn.close();
+
+ equal(parentActor.destroyedCount, 1, "parentActor was destroyed once");
+ equal(parentActor.destroyedCount, 1, "customActor was destroyed once");
+});
diff --git a/devtools/server/tests/xpcshell/test_console_eval-01.js b/devtools/server/tests/xpcshell/test_console_eval-01.js
new file mode 100644
index 0000000000..abb6ddc605
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_console_eval-01.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Check that is possible to evaluate JS with evaluation timeouts in place.
+ */
+
+add_task(
+ threadFrontTest(async ({ commands }) => {
+ await commands.scriptCommand.execute(`
+ function fib(n) {
+ if (n == 1 || n == 0) {
+ return 1;
+ }
+
+ return fib(n-1) + fib(n-2)
+ }
+ `);
+
+ const normalResult = await commands.scriptCommand.execute("fib(1)", {
+ eager: true,
+ });
+ Assert.equal(normalResult.result, 1, "normal eval");
+
+ const timeoutResult = await commands.scriptCommand.execute("fib(100)", {
+ eager: true,
+ });
+ Assert.equal(typeof timeoutResult.result, "object", "timeout eval");
+ Assert.equal(timeoutResult.result.type, "undefined", "timeout eval type");
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_console_eval-02.js b/devtools/server/tests/xpcshell/test_console_eval-02.js
new file mode 100644
index 0000000000..11b3d130b4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_console_eval-02.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Check that bound functions can be eagerly evaluated.
+ */
+
+add_task(
+ threadFrontTest(async ({ commands }) => {
+ await commands.scriptCommand.execute(`
+ var obj = [1, 2, 3];
+ var fn = obj.includes.bind(obj, 2);
+ `);
+
+ const normalResult = await commands.scriptCommand.execute("fn()", {
+ eager: true,
+ });
+ Assert.equal(normalResult.result, true, "normal eval");
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_dbgactor.js b/devtools/server/tests/xpcshell/test_dbgactor.js
new file mode 100644
index 0000000000..cb0cf8f7d7
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_dbgactor.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(
+ Ci.nsIJSInspector
+);
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ Assert.equal(xpcInspector.eventLoopNestLevel, 0);
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ Assert.equal(false, "error" in packet);
+ Assert.ok("actor" in packet);
+ Assert.ok("why" in packet);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ // Reach around the protocol to check that the debuggee is in the state
+ // we expect.
+ Assert.ok(debuggee.a);
+ Assert.ok(!debuggee.b);
+
+ Assert.equal(xpcInspector.eventLoopNestLevel, 1);
+
+ // Let the debuggee continue execution.
+ await threadFront.resume();
+
+ // Now make sure that we've run the code after the debugger statement...
+ Assert.ok(debuggee.b);
+
+ Assert.equal(xpcInspector.eventLoopNestLevel, 0);
+ })
+);
+
+function evalCode(debuggee) {
+ Cu.evalInSandbox(
+ "var a = true; var b = false; debugger; var b = true;",
+ debuggee
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_dbgclient_debuggerstatement.js b/devtools/server/tests/xpcshell/test_dbgclient_debuggerstatement.js
new file mode 100644
index 0000000000..254f582460
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_dbgclient_debuggerstatement.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(
+ Ci.nsIJSInspector
+);
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ Assert.equal(threadFront.state, "paused");
+ // Reach around the protocol to check that the debuggee is in the state
+ // we expect.
+ Assert.ok(debuggee.a);
+ Assert.ok(!debuggee.b);
+
+ Assert.equal(xpcInspector.eventLoopNestLevel, 1);
+
+ await threadFront.resume();
+
+ // Now make sure that we've run the code after the debugger statement...
+ Assert.ok(debuggee.b);
+
+ Assert.equal(xpcInspector.eventLoopNestLevel, 0);
+ })
+);
+
+function evalCode(debuggee) {
+ Cu.evalInSandbox(
+ "var a = true; var b = false; debugger; var b = true;",
+ debuggee
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_dbgglobal.js b/devtools/server/tests/xpcshell/test_dbgglobal.js
new file mode 100644
index 0000000000..407e270da4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_dbgglobal.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ SocketListener,
+} = require("resource://devtools/shared/security/socket.js");
+
+function run_test() {
+ // Should get an exception if we try to interact with DevToolsServer
+ // before we initialize it...
+ const socketListener = new SocketListener(DevToolsServer, {});
+ Assert.throws(
+ () => DevToolsServer.addSocketListener(socketListener),
+ /DevToolsServer has not been initialized/,
+ "addSocketListener should throw before it has been initialized"
+ );
+ Assert.throws(
+ DevToolsServer.closeAllSocketListeners,
+ /this is undefined/,
+ "closeAllSocketListeners should throw before it has been initialized"
+ );
+ Assert.throws(
+ DevToolsServer.connectPipe,
+ /this is undefined/,
+ "connectPipe should throw before it has been initialized"
+ );
+
+ // Allow incoming connections.
+ DevToolsServer.init();
+
+ // These should still fail because we haven't added a createRootActor
+ // implementation yet.
+ Assert.throws(
+ DevToolsServer.closeAllSocketListeners,
+ /this is undefined/,
+ "closeAllSocketListeners should throw if createRootActor hasn't been added"
+ );
+ Assert.throws(
+ DevToolsServer.connectPipe,
+ /this is undefined/,
+ "closeAllSocketListeners should throw if createRootActor hasn't been added"
+ );
+
+ const { createRootActor } = require("xpcshell-test/testactors");
+ DevToolsServer.setRootActor(createRootActor);
+
+ // Now they should work.
+ DevToolsServer.addSocketListener(socketListener);
+ DevToolsServer.closeAllSocketListeners();
+
+ // Make sure we got the test's root actor all set up.
+ const client1 = DevToolsServer.connectPipe();
+ client1.hooks = {
+ onPacket(packet1) {
+ Assert.equal(packet1.from, "root");
+ Assert.equal(packet1.applicationType, "xpcshell-tests");
+
+ // Spin up a second connection, make sure it has its own root
+ // actor.
+ const client2 = DevToolsServer.connectPipe();
+ client2.hooks = {
+ onPacket(packet2) {
+ Assert.equal(packet2.from, "root");
+ Assert.notEqual(
+ packet1.testConnectionPrefix,
+ packet2.testConnectionPrefix
+ );
+ client2.close();
+ },
+ onTransportClosed(result) {
+ client1.close();
+ },
+ };
+ client2.ready();
+ },
+
+ onTransportClosed(result) {
+ do_test_finished();
+ },
+ };
+
+ client1.ready();
+ do_test_pending();
+}
diff --git a/devtools/server/tests/xpcshell/test_extension_storage_actor.js b/devtools/server/tests/xpcshell/test_extension_storage_actor.js
new file mode 100644
index 0000000000..9816854cf8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_extension_storage_actor.js
@@ -0,0 +1,1155 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* globals browser */
+
+"use strict";
+
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+
+const {
+ createMissingIndexedDBDirs,
+ extensionScriptWithMessageListener,
+ ext_no_bg,
+ getExtensionConfig,
+ openAddonStoragePanel,
+ shutdown,
+ startupExtension,
+} = require("resource://test/webextension-helpers.js");
+
+const l10n = new Localization(["devtools/client/storage.ftl"], true);
+const sessionString = l10n.formatValueSync("storage-expires-session");
+
+// Ignore rejection related to the storage.onChanged listener being removed while the extension context is being closed.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /sendRemoveListener on closed conduit/
+);
+
+const { createAppInfo, promiseStartupManager } = AddonTestUtils;
+
+const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
+const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
+
+AddonTestUtils.init(this);
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+ExtensionTestUtils.init(this);
+
+add_setup(async function setup() {
+ await promiseStartupManager();
+ const dir = createMissingIndexedDBDirs();
+
+ Assert.ok(
+ dir.exists(),
+ "Should have a 'storage/permanent' dir in the profile dir"
+ );
+});
+
+add_task(async function test_extension_store_exists() {
+ const extension = await startupExtension(getExtensionConfig());
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ ok(extensionStorage, "Should have an extensionStorage store");
+
+ await shutdown(extension, commands);
+});
+
+add_task(
+ {
+ // This test currently fails if the extension runs in the main process
+ // like in Thunderbird (see bug 1575183 comment #15 for details).
+ skip_if: () => !WebExtensionPolicy.useRemoteWebExtensions,
+ },
+ async function test_extension_origin_matches_debugger_target() {
+ async function background() {
+ // window is available in background scripts
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage("extension-origin", window.location.origin);
+ }
+
+ const extension = await startupExtension(
+ getExtensionConfig({ background })
+ );
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const { hosts } = extensionStorage;
+ const expectedHost = await extension.awaitMessage("extension-origin");
+ ok(
+ expectedHost in hosts,
+ "Should have the expected extension host in the extensionStorage store"
+ );
+
+ await shutdown(extension, commands);
+ }
+);
+
+/**
+ * Test case: Background page modifies items while storage panel is open.
+ * - Load extension with background page.
+ * - Open the add-on debugger storage panel.
+ * - With the panel still open, from the extension background page:
+ * - Bulk add storage items
+ * - Edit the values of some of the storage items
+ * - Remove some storage items
+ * - Clear all storage items
+ * - For each modification, the storage data in the panel should match the
+ * changes made by the extension.
+ */
+add_task(async function test_panel_live_updates() {
+ const extension = await startupExtension(
+ getExtensionConfig({ background: extensionScriptWithMessageListener })
+ );
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ let { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(data, [], "Got the expected results on empty storage.local");
+
+ info("Waiting for extension to bulk add 50 items to storage local");
+ const bulkStorageItems = {};
+ // limited by MAX_STORE_OBJECT_COUNT in devtools/server/actors/resources/storage/index.js
+ const numItems = 2;
+ for (let i = 1; i <= numItems; i++) {
+ bulkStorageItems[i] = i;
+ }
+
+ // fireOnChanged avoids the race condition where the extension
+ // modifies storage then immediately tries to access storage before
+ // the storage actor has finished updating.
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", {
+ ...bulkStorageItems,
+ a: 123,
+ b: [4, 5],
+ c: { d: 678 },
+ d: true,
+ e: "hi",
+ f: null,
+ });
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ info(
+ "Confirming items added by extension match items in extensionStorage store"
+ );
+ const bulkStorageObjects = [];
+ for (const [name, value] of Object.entries(bulkStorageItems)) {
+ bulkStorageObjects.push({
+ area: "local",
+ name,
+ value: { str: String(value) },
+ isValueEditable: true,
+ });
+ }
+ data = (await extensionStorage.getStoreObjects(host, null, { sessionString }))
+ .data;
+ Assert.deepEqual(
+ data,
+ [
+ ...bulkStorageObjects,
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "b",
+ value: { str: "[4,5]" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "c",
+ value: { str: '{"d":678}' },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "d",
+ value: { str: "true" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "e",
+ value: { str: "hi" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "f",
+ value: { str: "null" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ info("Waiting for extension to edit a few storage item values");
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", {
+ a: ["c", "d"],
+ b: 456,
+ c: false,
+ });
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ info(
+ "Confirming items edited by extension match items in extensionStorage store"
+ );
+ data = (await extensionStorage.getStoreObjects(host, null, { sessionString }))
+ .data;
+ Assert.deepEqual(
+ data,
+ [
+ ...bulkStorageObjects,
+ {
+ area: "local",
+ name: "a",
+ value: { str: '["c","d"]' },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "b",
+ value: { str: "456" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "c",
+ value: { str: "false" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "d",
+ value: { str: "true" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "e",
+ value: { str: "hi" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "f",
+ value: { str: "null" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ info("Waiting for extension to remove a few storage item values");
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-remove", ["d", "e", "f"]);
+ await extension.awaitMessage("storage-local-remove:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ info(
+ "Confirming items removed by extension were removed in extensionStorage store"
+ );
+ data = (await extensionStorage.getStoreObjects(host, null, { sessionString }))
+ .data;
+ Assert.deepEqual(
+ data,
+ [
+ ...bulkStorageObjects,
+ {
+ area: "local",
+ name: "a",
+ value: { str: '["c","d"]' },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "b",
+ value: { str: "456" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "c",
+ value: { str: "false" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ info("Waiting for extension to remove all remaining storage items");
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-clear");
+ await extension.awaitMessage("storage-local-clear:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ info("Confirming extensionStorage store was cleared");
+ data = (await extensionStorage.getStoreObjects(host)).data;
+ Assert.deepEqual(
+ data,
+ [],
+ "Got the expected results on populated storage.local"
+ );
+
+ await shutdown(extension, commands);
+});
+
+/**
+ * Test case: No bg page. Transient page adds item before storage panel opened.
+ * - Load extension with no background page.
+ * - Open an extension page in a tab that adds a local storage item.
+ * - With the extension page still open, open the add-on storage panel.
+ * - The data in the storage panel should match the items added by the extension.
+ */
+add_task(
+ async function test_panel_data_matches_extension_with_transient_page_open() {
+ const extension = await startupExtension(
+ getExtensionConfig({ files: ext_no_bg.files })
+ );
+
+ const url = extension.extension.baseURI.resolve(
+ "extension_page_in_tab.html"
+ );
+ const contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ extension,
+ });
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ extension.sendMessage("storage-local-set", { a: 123 });
+ await extension.awaitMessage("storage-local-set:done");
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ await contentPage.close();
+ await shutdown(extension, commands);
+ }
+);
+
+/**
+ * Test case: No bg page. Transient page adds item then closes before storage panel opened.
+ * - Load extension with no background page.
+ * - Open an extension page in a tab that adds a local storage item.
+ * - Close all extension pages.
+ * - Open the add-on storage panel.
+ * - The data in the storage panel should match the item added by the extension.
+ */
+add_task(async function test_panel_data_matches_extension_with_no_pages_open() {
+ const extension = await startupExtension(
+ getExtensionConfig({ files: ext_no_bg.files })
+ );
+
+ const url = extension.extension.baseURI.resolve("extension_page_in_tab.html");
+ const contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ extension,
+ });
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", { a: 123 });
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+ await contentPage.close();
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ await shutdown(extension, commands);
+});
+
+/**
+ * Test case: No bg page. Storage panel live updates when a transient page adds an item.
+ * - Load extension with no background page.
+ * - Open the add-on storage panel.
+ * - With the storage panel still open, open an extension page in a new tab that adds an
+ * item.
+ * - The data in the storage panel should live update to match the item added by the
+ * extension.
+ * - If an extension page adds the same data again, the data in the storage panel should
+ * not change.
+ */
+add_task(
+ async function test_panel_data_live_updates_for_extension_without_bg_page() {
+ const extension = await startupExtension(
+ getExtensionConfig({ files: ext_no_bg.files })
+ );
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const url = extension.extension.baseURI.resolve(
+ "extension_page_in_tab.html"
+ );
+ const contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ extension,
+ });
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ let { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(
+ data,
+ [],
+ "Got the expected results on empty storage.local"
+ );
+
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", { a: 123 });
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ data = (await extensionStorage.getStoreObjects(host)).data;
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", { a: 123 });
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ data = (await extensionStorage.getStoreObjects(host)).data;
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ ],
+ "The results are unchanged when an extension page adds duplicate items"
+ );
+
+ await contentPage.close();
+ await shutdown(extension, commands);
+ }
+);
+
+/**
+ * Test case: Bg page adds item while storage panel is open. Panel edits item's value.
+ * - Load extension with background page.
+ * - Open the add-on storage panel.
+ * - With the storage panel still open, add item from the background page.
+ * - Edit the value of the item in the storage panel
+ * - The data in the storage panel should match the item added by the extension.
+ * - The storage actor is correctly parsing and setting the string representation of
+ * the value in the storage local database when the item's value is edited in the
+ * storage panel
+ */
+add_task(
+ async function test_editing_items_in_panel_parses_supported_values_correctly() {
+ const extension = await startupExtension(
+ getExtensionConfig({ background: extensionScriptWithMessageListener })
+ );
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const oldItem = { a: 123 };
+ const key = Object.keys(oldItem)[0];
+ const oldValue = oldItem[key];
+ // A tuple representing information for a new value entered into the panel for oldItem:
+ // [
+ // value,
+ // editItem string representation of value,
+ // toStoreObject string representation of value,
+ // ]
+ const valueInfo = [
+ [true, "true", "true"],
+ ["hi", "hi", "hi"],
+ [456, "456", "456"],
+ [{ b: 789 }, "{b: 789}", '{"b":789}'],
+ [[1, 2, 3], "[1, 2, 3]", "[1,2,3]"],
+ [null, "null", "null"],
+ ];
+ for (const [value, editItemValueStr, toStoreObjectValueStr] of valueInfo) {
+ info("Setting a storage item through the extension");
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", oldItem);
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ info(
+ "Editing the storage item in the panel with a new value of a different type"
+ );
+ // When the user edits an item in the panel, they are entering a string into a
+ // textbox. This string is parsed by the storage actor's editItem method.
+ await extensionStorage.editItem({
+ host,
+ field: "value",
+ items: { name: key, value: editItemValueStr },
+ oldValue,
+ });
+
+ info(
+ "Verifying item in the storage actor matches the item edited in the panel"
+ );
+ const { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: key,
+ value: { str: toStoreObjectValueStr },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ // The view layer is separate from the database layer; therefore while values are
+ // stringified (via toStoreObject) for display in the client, the value (and its type)
+ // in the database is unchanged.
+ info(
+ "Verifying the expected new value matches the value fetched in the extension"
+ );
+ extension.sendMessage("storage-local-get", key);
+ const extItem = await extension.awaitMessage("storage-local-get:done");
+ Assert.deepEqual(
+ value,
+ extItem[key],
+ `The string value ${editItemValueStr} was correctly parsed to ${value}`
+ );
+ }
+
+ await shutdown(extension, commands);
+ }
+);
+
+/**
+ * Test case: Modifying storage items from the panel update extension storage local data.
+ * - Load extension with background page.
+ * - Open the add-on storage panel. From the panel:
+ * - Edit the value of a storage item,
+ * - Remove a storage item,
+ * - Remove all of the storage items,
+ * - For each modification, the storage data retrieved by the extension should match the
+ * data in the panel.
+ */
+add_task(
+ async function test_modifying_items_in_panel_updates_extension_storage_data() {
+ const extension = await startupExtension(
+ getExtensionConfig({ background: extensionScriptWithMessageListener })
+ );
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const DEFAULT_VALUE = "value"; // global in devtools/server/actors/resources/storage/index.js
+ let items = {
+ guid_1: DEFAULT_VALUE,
+ guid_2: DEFAULT_VALUE,
+ guid_3: DEFAULT_VALUE,
+ };
+
+ info("Adding storage items from the extension");
+ let storesUpdate = extensionStorage.once("single-store-update");
+ extension.sendMessage("storage-local-set", items);
+ await extension.awaitMessage("storage-local-set:done");
+
+ info("Waiting for the storage actor to emit a 'stores-update' event");
+ let data = await storesUpdate;
+ Assert.deepEqual(
+ {
+ added: {
+ extensionStorage: {
+ [host]: ["guid_1", "guid_2", "guid_3"],
+ },
+ },
+ changed: undefined,
+ deleted: undefined,
+ },
+ data,
+ "The change data from the storage actor's 'stores-update' event matches the changes made in the client."
+ );
+
+ info("Waiting for panel to edit some items");
+ storesUpdate = extensionStorage.once("single-store-update");
+ await extensionStorage.editItem({
+ host,
+ field: "value",
+ items: { name: "guid_1", value: "anotherValue" },
+ DEFAULT_VALUE,
+ });
+
+ info("Waiting for the storage actor to emit a 'stores-update' event");
+ data = await storesUpdate;
+ Assert.deepEqual(
+ {
+ added: undefined,
+ changed: {
+ extensionStorage: {
+ [host]: ["guid_1"],
+ },
+ },
+ deleted: undefined,
+ },
+ data,
+ "The change data from the storage actor's 'stores-update' event matches the changes made in the client."
+ );
+
+ items = {
+ guid_1: "anotherValue",
+ guid_2: DEFAULT_VALUE,
+ guid_3: DEFAULT_VALUE,
+ };
+ extension.sendMessage("storage-local-get", Object.keys(items));
+ let extItems = await extension.awaitMessage("storage-local-get:done");
+ Assert.deepEqual(
+ items,
+ extItems,
+ `The storage items in the extension match the items in the panel`
+ );
+
+ info("Waiting for panel to remove an item");
+ storesUpdate = extensionStorage.once("single-store-update");
+ await extensionStorage.removeItem(host, "guid_3");
+
+ info("Waiting for the storage actor to emit a 'stores-update' event");
+ data = await storesUpdate;
+ Assert.deepEqual(
+ {
+ added: undefined,
+ changed: undefined,
+ deleted: {
+ extensionStorage: {
+ [host]: ["guid_3"],
+ },
+ },
+ },
+ data,
+ "The change data from the storage actor's 'stores-update' event matches the changes made in the client."
+ );
+
+ items = {
+ guid_1: "anotherValue",
+ guid_2: DEFAULT_VALUE,
+ };
+ extension.sendMessage("storage-local-get", Object.keys(items));
+ extItems = await extension.awaitMessage("storage-local-get:done");
+ Assert.deepEqual(
+ items,
+ extItems,
+ `The storage items in the extension match the items in the panel`
+ );
+
+ info("Waiting for panel to remove all items");
+ const storesCleared = extensionStorage.once("single-store-cleared");
+ await extensionStorage.removeAll(host);
+
+ info("Waiting for the storage actor to emit a 'stores-cleared' event");
+ data = await storesCleared;
+ Assert.deepEqual(
+ {
+ clearedHostsOrPaths: {
+ [host]: [],
+ },
+ },
+ data,
+ "The change data from the storage actor's 'stores-cleared' event matches the changes made in the client."
+ );
+
+ items = {};
+ extension.sendMessage("storage-local-get", Object.keys(items));
+ extItems = await extension.awaitMessage("storage-local-get:done");
+ Assert.deepEqual(
+ items,
+ extItems,
+ `The storage items in the extension match the items in the panel`
+ );
+
+ await shutdown(extension, commands);
+ }
+);
+
+/**
+ * Test case: Storage panel shows extension storage data added prior to extension startup
+ * - Load extension that adds a storage item
+ * - Uninstall the extension
+ * - Reinstall the extension
+ * - Open the add-on storage panel.
+ * - The data in the storage panel should match the data added the first time the extension
+ * was installed
+ * Related test case: Storage panel shows extension storage data when an extension that has
+ * already migrated to the IndexedDB storage backend prior to extension startup adds
+ * another storage item.
+ * - (Building from previous steps)
+ * - The reinstalled extension adds a storage item
+ * - The data in the storage panel should live update with both items: the item added from
+ * the first and the item added from the reinstall.
+ */
+add_task(
+ async function test_panel_data_matches_data_added_prior_to_ext_startup() {
+ // The pref to leave the addonid->uuid mapping around after uninstall so that we can
+ // re-attach to the same storage
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, true);
+
+ // The pref to prevent cleaning up storage on uninstall
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true);
+
+ let extension = await startupExtension(
+ getExtensionConfig({ background: extensionScriptWithMessageListener })
+ );
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ extension.sendMessage("storage-local-set", { a: 123 });
+ await extension.awaitMessage("storage-local-set:done");
+
+ await shutdown(extension);
+
+ // Reinstall the same extension
+ extension = await startupExtension(
+ getExtensionConfig({ background: extensionScriptWithMessageListener })
+ );
+
+ await extension.awaitMessage("extension-origin");
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ let { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ // Related test case
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", { b: 456 });
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+
+ data = (
+ await extensionStorage.getStoreObjects(host, null, { sessionString })
+ ).data;
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "b",
+ value: { str: "456" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false);
+ Services.prefs.setBoolPref(LEAVE_UUID_PREF, false);
+
+ await shutdown(extension, commands);
+ }
+);
+
+add_task(
+ function cleanup_for_test_panel_data_matches_data_added_prior_to_ext_startup() {
+ Services.prefs.clearUserPref(LEAVE_UUID_PREF);
+ Services.prefs.clearUserPref(LEAVE_STORAGE_PREF);
+ }
+);
+
+/**
+ * Test case: Transient page adds an item to storage. With storage panel open,
+ * reload extension.
+ * - Load extension with no background page.
+ * - Open transient page that adds a storage item on message.
+ * - Open the add-on storage panel.
+ * - With the storage panel still open, reload the extension.
+ * - The data in the storage panel should match the item added prior to reloading.
+ */
+add_task(async function test_panel_live_reload_for_extension_without_bg_page() {
+ const EXTENSION_ID = "test_local_storage_live_reload@xpcshell.mozilla.org";
+ let manifest = {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ };
+
+ info("Loading and starting extension version 1.0");
+ const extension = await startupExtension(
+ getExtensionConfig({
+ manifest,
+ files: ext_no_bg.files,
+ })
+ );
+
+ info("Opening extension page in a tab");
+ const url = extension.extension.baseURI.resolve("extension_page_in_tab.html");
+ const contentPage = await ExtensionTestUtils.loadContentPage(url, {
+ extension,
+ });
+
+ const host = await extension.awaitMessage("extension-origin");
+
+ info("Waiting for extension page in a tab to add storage item");
+ extension.sendMessage("storage-local-fireOnChanged");
+ extension.sendMessage("storage-local-set", { a: 123 });
+ await extension.awaitMessage("storage-local-set:done");
+ await extension.awaitMessage("storage-local-onChanged");
+ await contentPage.close();
+
+ info("Opening storage panel");
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ manifest = {
+ ...manifest,
+ version: "2.0",
+ };
+ // "Reload" is most similar to an upgrade, as e.g. storage data is preserved
+ info("Updating extension to version 2.0");
+ await extension.upgrade(
+ getExtensionConfig({
+ manifest,
+ files: ext_no_bg.files,
+ })
+ );
+
+ const { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ await shutdown(extension, commands);
+});
+
+/**
+ * Test case: Bg page auto adds item(s). With storage panel open, reload extension.
+ * - Load extension with background page that automatically adds a storage item on startup.
+ * - Open the add-on storage panel.
+ * - With the storage panel still open, reload the extension.
+ * - The data in the storage panel should match the item(s) added by the reloaded
+ * extension.
+ */
+add_task(
+ async function test_panel_live_reload_when_extension_auto_adds_items() {
+ async function background() {
+ await browser.storage.local.set({ a: { b: 123 }, c: { d: 456 } });
+ // window is available in background scripts
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage("extension-origin", window.location.origin);
+ }
+ const EXTENSION_ID = "test_local_storage_live_reload@xpcshell.mozilla.org";
+ let manifest = {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ };
+
+ info("Loading and starting extension version 1.0");
+ const extension = await startupExtension(
+ getExtensionConfig({ manifest, background })
+ );
+
+ info("Waiting for message from test extension");
+ const host = await extension.awaitMessage("extension-origin");
+
+ info("Opening storage panel");
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ manifest = {
+ ...manifest,
+ version: "2.0",
+ };
+ // "Reload" is most similar to an upgrade, as e.g. storage data is preserved
+ info("Update to version 2.0");
+ await extension.upgrade(
+ getExtensionConfig({
+ manifest,
+ background,
+ })
+ );
+
+ await extension.awaitMessage("extension-origin");
+
+ const { data } = await extensionStorage.getStoreObjects(host, null, {
+ sessionString,
+ });
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: '{"b":123}' },
+ isValueEditable: true,
+ },
+ {
+ area: "local",
+ name: "c",
+ value: { str: '{"d":456}' },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ await shutdown(extension, commands);
+ }
+);
+
+/**
+ * Test case: Bg page adds one storage.local item and one storage.sync item.
+ * - Load extension with background page that automatically adds two storage items on startup.
+ * - Open the add-on storage panel.
+ * - Assert that only the storage.local item is shown in the panel.
+ */
+add_task(
+ async function test_panel_data_only_updates_for_storage_local_changes() {
+ async function background() {
+ await browser.storage.local.set({ a: { b: 123 } });
+ await browser.storage.sync.set({ c: { d: 456 } });
+ // window is available in background scripts
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage("extension-origin", window.location.origin);
+ }
+
+ // Using the storage.sync API requires a non-temporary extension ID, see Bug 1323228.
+ const EXTENSION_ID =
+ "test_panel_data_only_updates_for_storage_local_changes@xpcshell.mozilla.org";
+ const manifest = {
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ };
+
+ info("Loading and starting extension");
+ const extension = await startupExtension(
+ getExtensionConfig({ manifest, background })
+ );
+
+ info("Waiting for message from test extension");
+ const host = await extension.awaitMessage("extension-origin");
+
+ info("Opening storage panel");
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: '{"b":123}' },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ await shutdown(extension, commands);
+ }
+);
+
+// This test verifies that Bug 1802929 fix doesn't regress.
+add_task(async function test_live_update_with_no_extension_listener() {
+ const EXTENSION_ID = "test_with_no_listeners@xpcshell.mozilla.org";
+ let manifest = {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ };
+
+ function background() {
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ if (msg !== "storage-local-api-call") {
+ browser.test.fail(`Got unexpected test message: ${msg}`);
+ return;
+ }
+
+ const [{ method, methodArgs }] = args;
+ const res = await browser.storage.local[method](...methodArgs);
+ browser.test.sendMessage(`${msg}:done`, res);
+ });
+ }
+
+ const extension = await startupExtension(
+ getExtensionConfig({ manifest, background })
+ );
+
+ const { target, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ const { baseURI } = extension.extension;
+ const host = `${baseURI.scheme}://${baseURI.host}`;
+
+ let { data } = await extensionStorage.getStoreObjects(host);
+ Assert.deepEqual(data, [], "Got the expected results on empty storage.local");
+
+ async function testStorageLocalUpdate(storageValue) {
+ info("Store extension data");
+ await extension.sendMessage("storage-local-api-call", {
+ method: "set",
+ methodArgs: [{ storageKeyName: storageValue }],
+ });
+ await extension.awaitMessage("storage-local-api-call:done");
+
+ info("Verify stored extension data");
+ await extension.sendMessage("storage-local-api-call", {
+ method: "get",
+ methodArgs: [],
+ });
+
+ Assert.deepEqual(
+ await extension.awaitMessage("storage-local-api-call:done"),
+ { storageKeyName: storageValue },
+ "Got the expected value from browser.storage.local.get"
+ );
+
+ await TestUtils.waitForCondition(async () => {
+ const res = await extensionStorage.getStoreObjects(host);
+ return res.data?.length > 0;
+ }, "Wait for the extension storage panel updates");
+
+ data = (await extensionStorage.getStoreObjects(host)).data;
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "storageKeyName",
+ value: { str: `${storageValue}` },
+ isValueEditable: true,
+ },
+ ],
+ "Expected DevTools Storage panel data to have been updated"
+ );
+ }
+
+ await testStorageLocalUpdate("aStorageValue 01");
+
+ manifest = {
+ ...manifest,
+ version: "2.0",
+ };
+ // "Reload" is most similar to an upgrade, as e.g. storage data is preserved
+ info("Update to version 2.0");
+ await extension.upgrade(getExtensionConfig({ manifest, background }));
+
+ await testStorageLocalUpdate("aStorageValue 02");
+
+ await shutdown(extension, target);
+});
diff --git a/devtools/server/tests/xpcshell/test_extension_storage_actor_upgrade.js b/devtools/server/tests/xpcshell/test_extension_storage_actor_upgrade.js
new file mode 100644
index 0000000000..5d2285b9e8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_extension_storage_actor_upgrade.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Note: this test used to be in test_extension_storage_actor.js, but seems to
+ * fail frequently as soon as we start auto-attaching targets.
+ * See Bug 1618059.
+ */
+
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+const {
+ createMissingIndexedDBDirs,
+ extensionScriptWithMessageListener,
+ getExtensionConfig,
+ openAddonStoragePanel,
+ shutdown,
+ startupExtension,
+} = require("resource://test/webextension-helpers.js");
+
+const l10n = new Localization(["devtools/client/storage.ftl"], true);
+const sessionString = l10n.formatValueSync("storage-expires-session");
+
+// Ignore rejection related to the storage.onChanged listener being removed while the extension context is being closed.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(
+ /Message manager disconnected/
+);
+
+const { createAppInfo, promiseStartupManager } = AddonTestUtils;
+
+AddonTestUtils.init(this);
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
+
+ExtensionTestUtils.init(this);
+
+add_task(async function setup() {
+ await promiseStartupManager();
+ const dir = createMissingIndexedDBDirs();
+
+ Assert.ok(
+ dir.exists(),
+ "Should have a 'storage/permanent' dir in the profile dir"
+ );
+});
+
+/**
+ * Test case: Bg page adds an item to storage. With storage panel open, reload extension.
+ * - Load extension with background page that adds a storage item on message.
+ * - Open the add-on storage panel.
+ * - With the storage panel still open, reload the extension.
+ * - The data in the storage panel should match the item added prior to reloading.
+ */
+add_task(async function test_panel_live_reload() {
+ const EXTENSION_ID = "test_panel_live_reload@xpcshell.mozilla.org";
+ let manifest = {
+ version: "1.0",
+ browser_specific_settings: {
+ gecko: {
+ id: EXTENSION_ID,
+ },
+ },
+ };
+
+ info("Loading extension version 1.0");
+ const extension = await startupExtension(
+ getExtensionConfig({
+ manifest,
+ background: extensionScriptWithMessageListener,
+ })
+ );
+
+ info("Waiting for message from test extension");
+ const host = await extension.awaitMessage("extension-origin");
+
+ info("Adding storage item");
+ extension.sendMessage("storage-local-set", { a: 123 });
+ await extension.awaitMessage("storage-local-set:done");
+
+ const { commands, extensionStorage } = await openAddonStoragePanel(
+ extension.id
+ );
+
+ manifest = {
+ ...manifest,
+ version: "2.0",
+ };
+ // "Reload" is most similar to an upgrade, as e.g. storage data is preserved
+ info("Update to version 2.0");
+
+ // Wait for the storage front to receive an event for the storage panel refresh
+ // when the extension has been reloaded.
+ const promiseStoragePanelUpdated = new Promise(resolve => {
+ extensionStorage.on(
+ "single-store-update",
+ function updateListener(updates) {
+ info(`Got stores-update event: ${JSON.stringify(updates)}`);
+ const extStorageAdded = updates.added?.extensionStorage;
+ if (host in extStorageAdded && extStorageAdded[host].length) {
+ extensionStorage.off("single-store-update", updateListener);
+ resolve();
+ }
+ }
+ );
+ });
+
+ await extension.upgrade(
+ getExtensionConfig({
+ manifest,
+ background: extensionScriptWithMessageListener,
+ })
+ );
+
+ await Promise.all([
+ extension.awaitMessage("extension-origin"),
+ promiseStoragePanelUpdated,
+ ]);
+
+ const { data } = await extensionStorage.getStoreObjects(host, null, {
+ sessionString,
+ });
+ Assert.deepEqual(
+ data,
+ [
+ {
+ area: "local",
+ name: "a",
+ value: { str: "123" },
+ isValueEditable: true,
+ },
+ ],
+ "Got the expected results on populated storage.local"
+ );
+
+ await shutdown(extension, commands);
+});
diff --git a/devtools/server/tests/xpcshell/test_forwardingprefix.js b/devtools/server/tests/xpcshell/test_forwardingprefix.js
new file mode 100644
index 0000000000..e917350da5
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_forwardingprefix.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* Exercise prefix-based forwarding of packets to other transports. */
+
+const { RootActor } = require("resource://devtools/server/actors/root.js");
+
+var gMainConnection, gMainTransport;
+var gSubconnection1, gSubconnection2;
+var gClient;
+
+function run_test() {
+ DevToolsServer.init();
+
+ add_test(createMainConnection);
+ add_test(TestNoForwardingYet);
+ add_test(createSubconnection1);
+ add_test(TestForwardPrefix1OnlyRoot);
+ add_test(createSubconnection2);
+ add_test(TestForwardPrefix12OnlyRoot);
+ add_test(TestForwardPrefix12WithActor1);
+ add_test(TestForwardPrefix12WithActor12);
+ run_next_test();
+}
+
+/*
+ * Create a pipe connection, and return an object |{ conn, transport }|,
+ * where |conn| is the new DevToolsServerConnection instance, and
+ * |transport| is the client side of the transport on which it communicates
+ * (that is, packets sent on |transport| go to the new connection, and
+ * |transport|'s hooks receive replies).
+ *
+ * |prefix| is optional; if present, it's the prefix (minus the '/') for
+ * actors in the new connection.
+ */
+function newConnection(prefix) {
+ let conn;
+ DevToolsServer.createRootActor = function (connection) {
+ conn = connection;
+ return new RootActor(connection, {});
+ };
+
+ const transport = DevToolsServer.connectPipe(prefix);
+
+ return { conn, transport };
+}
+
+/* Create the main connection for these tests. */
+function createMainConnection() {
+ ({ conn: gMainConnection, transport: gMainTransport } = newConnection());
+ gClient = new DevToolsClient(gMainTransport);
+ gClient.connect().then(([type, traits]) => run_next_test());
+}
+
+/*
+ * Exchange 'echo' messages with five actors:
+ * - root
+ * - prefix1/root
+ * - prefix1/actor
+ * - prefix2/root
+ * - prefix2/actor
+ *
+ * Expect proper echos from those named in |reachables|, and 'noSuchActor'
+ * errors from the others. When we've gotten all our replies (errors or
+ * otherwise), call |completed|.
+ *
+ * To avoid deep stacks, we call completed from the next tick.
+ */
+async function tryActors(reachables, completed) {
+ for (const actor of [
+ "root",
+ "prefix1/root",
+ "prefix1/actor",
+ "prefix2/root",
+ "prefix2/actor",
+ ]) {
+ let response;
+ try {
+ if (actor.endsWith("root")) {
+ // Root actor doesn't expose any echo method,
+ // so fallback on getRoot which returns `{ from: "root" }`.
+ // For the top level root actor, we have to use its front.
+ if (actor == "root") {
+ response = await gClient.mainRoot.getRoot();
+ } else {
+ response = await gClient.request({ to: actor, type: "getRoot" });
+ }
+ } else {
+ response = await gClient.request({
+ to: actor,
+ type: "echo",
+ value: "tango",
+ });
+ }
+ } catch (e) {
+ response = e;
+ }
+ if (reachables.has(actor)) {
+ if (actor.endsWith("root")) {
+ // RootActor's getRoot response is almost empty on xpcshell
+ Assert.deepEqual({ from: actor }, response);
+ } else {
+ Assert.deepEqual(
+ { from: actor, to: actor, type: "echo", value: "tango" },
+ response
+ );
+ }
+ } else {
+ Assert.deepEqual(
+ {
+ from: actor,
+ error: "noSuchActor",
+ message: "No such actor for ID: " + actor,
+ },
+ response
+ );
+ }
+ }
+ executeSoon(completed, "tryActors callback " + completed.name);
+}
+
+/*
+ * With no forwarding established, sending messages to root should work,
+ * but sending messages to prefixed actor names, or anyone else, should get
+ * an error.
+ */
+function TestNoForwardingYet() {
+ tryActors(new Set(["root"]), run_next_test);
+}
+
+/*
+ * Create a new pipe connection which forwards its reply packets to
+ * gMainConnection's client, and to which gMainConnection forwards packets
+ * directed to actors whose names begin with |prefix + '/'|, and.
+ *
+ * Return an object { conn, transport }, as for newConnection.
+ */
+function newSubconnection(prefix) {
+ const { conn, transport } = newConnection(prefix);
+ transport.hooks = {
+ onPacket: packet => gMainConnection.send(packet),
+ };
+ gMainConnection.setForwarding(prefix, transport);
+
+ return { conn, transport };
+}
+
+/* Create a second root actor, to which we can forward things. */
+function createSubconnection1() {
+ const { conn, transport } = newSubconnection("prefix1");
+ gSubconnection1 = conn;
+ transport.ready();
+ gClient.expectReply("prefix1/root", reply => run_next_test());
+}
+
+// Establish forwarding, but don't put any actors in that server.
+function TestForwardPrefix1OnlyRoot() {
+ tryActors(new Set(["root", "prefix1/root"]), run_next_test);
+}
+
+/* Create a third root actor, to which we can forward things. */
+function createSubconnection2() {
+ const { conn, transport } = newSubconnection("prefix2");
+ gSubconnection2 = conn;
+ transport.ready();
+ gClient.expectReply("prefix2/root", reply => run_next_test());
+}
+
+function TestForwardPrefix12OnlyRoot() {
+ tryActors(new Set(["root", "prefix1/root", "prefix2/root"]), run_next_test);
+}
+
+// A dumb actor that implements 'echo'.
+//
+// It's okay that both subconnections' actors behave identically, because
+// the reply-sending code attaches the replying actor's name to the packet,
+// so simply matching the 'from' field in the reply ensures that we heard
+// from the right actor.
+const { Actor } = require("resource://devtools/shared/protocol/Actor.js");
+class EchoActor extends Actor {
+ constructor(conn) {
+ super(conn, { typeName: "EchoActor", methods: [] });
+
+ this.requestTypes = {
+ echo: EchoActor.prototype.onEcho,
+ };
+ }
+
+ onEcho(request) {
+ /*
+ * Request packets are frozen. Copy request, so that
+ * DevToolsServerConnection.onPacket can attach a 'from' property.
+ */
+ return JSON.parse(JSON.stringify(request));
+ }
+}
+
+function TestForwardPrefix12WithActor1() {
+ const actor = new EchoActor(gSubconnection1);
+ actor.actorID = "prefix1/actor";
+ gSubconnection1.addActor(actor);
+
+ tryActors(
+ new Set(["root", "prefix1/root", "prefix1/actor", "prefix2/root"]),
+ run_next_test
+ );
+}
+
+function TestForwardPrefix12WithActor12() {
+ const actor = new EchoActor(gSubconnection2);
+ actor.actorID = "prefix2/actor";
+ gSubconnection2.addActor(actor);
+
+ tryActors(
+ new Set([
+ "root",
+ "prefix1/root",
+ "prefix1/actor",
+ "prefix2/root",
+ "prefix2/actor",
+ ]),
+ run_next_test
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_frameactor-01.js b/devtools/server/tests/xpcshell/test_frameactor-01.js
new file mode 100644
index 0000000000..18c75d0abe
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_frameactor-01.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verify that we get a frame actor along with a debugger statement.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ Assert.ok(!!packet.frame);
+ Assert.ok(!!packet.frame.getActorByID);
+ Assert.equal(packet.frame.displayName, "stopMe");
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe() {
+ debugger;
+ }
+ stopMe();
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_frameactor-02.js b/devtools/server/tests/xpcshell/test_frameactor-02.js
new file mode 100644
index 0000000000..9529d2f324
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_frameactor-02.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verify that two pauses in a row will keep the same frame actor.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet1 = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const packet2 = await resumeAndWaitForPause(threadFront);
+
+ Assert.equal(packet1.frame.actor, packet2.frame.actor);
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe() {
+ debugger;
+ debugger;
+ }
+ stopMe();
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_frameactor-03.js b/devtools/server/tests/xpcshell/test_frameactor-03.js
new file mode 100644
index 0000000000..7feecd14e0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_frameactor-03.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verify that a frame actor is properly expired when the frame goes away.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+ const frameActorID = packet.frame.actorID;
+ {
+ const { frames } = await threadFront.getFrames(0, null);
+ ok(
+ frames.some(f => f.actorID === frameActorID),
+ "The paused frame is returned by getFrames"
+ );
+
+ Assert.equal(frames.length, 3, "Thread front has 3 frames");
+ }
+
+ await resumeAndWaitForPause(threadFront);
+ await checkFramesLength(threadFront, 2);
+ {
+ const { frames } = await threadFront.getFrames(0, null);
+ ok(
+ !frames.some(f => f.actorID === frameActorID),
+ "The paused frame is no longer returned by getFrames"
+ );
+
+ Assert.equal(frames.length, 2, "Thread front has 2 frames");
+ }
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe() {
+ debugger;
+ }
+ stopMe();
+ debugger;
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_frameactor-04.js b/devtools/server/tests/xpcshell/test_frameactor-04.js
new file mode 100644
index 0000000000..200ee9968d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_frameactor-04.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verify the "frames" request on the thread.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const response = await threadFront.getFrames(0, 1000);
+ for (let i = 0; i < response.frames.length; i++) {
+ const expected = frameFixtures[i];
+ const actual = response.frames[i];
+
+ Assert.equal(
+ expected.displayname,
+ actual.displayname,
+ "Frame displayname"
+ );
+ Assert.equal(expected.type, actual.type, "Frame displayname");
+ }
+
+ await threadFront.resume();
+ })
+);
+
+var frameFixtures = [
+ // Function calls...
+ { type: "call", displayName: "depth3" },
+ { type: "call", displayName: "depth2" },
+ { type: "call", displayName: "depth1" },
+
+ // Anonymous function call in our eval...
+ { type: "call", displayName: undefined },
+
+ // The eval itself.
+ { type: "eval", displayName: "(eval)" },
+];
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function depth3() {
+ debugger;
+ }
+ function depth2() {
+ depth3();
+ }
+ function depth1() {
+ depth2();
+ }
+ depth1();
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_frameactor-05.js b/devtools/server/tests/xpcshell/test_frameactor-05.js
new file mode 100644
index 0000000000..90456191e7
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_frameactor-05.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+ await checkFramesLength(threadFront, 5);
+
+ await resumeAndWaitForPause(threadFront);
+ await checkFramesLength(threadFront, 2);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function depth3() {
+ debugger;
+ }
+ function depth2() {
+ depth3();
+ }
+ function depth1() {
+ depth2();
+ }
+ depth1();
+ debugger;
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_frameactor_wasm-01.js b/devtools/server/tests/xpcshell/test_frameactor_wasm-01.js
new file mode 100644
index 0000000000..5967e8a086
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_frameactor_wasm-01.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verify that wasm frame(s) can be requested from the client.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await threadFront.reconfigure({
+ observeAsmJS: true,
+ observeWasm: true,
+ });
+
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const frameResponse = await threadFront.getFrames(0, null);
+
+ Assert.equal(frameResponse.frames.length, 4);
+
+ const wasmFrame = frameResponse.frames[1];
+ Assert.equal(wasmFrame.type, "wasmcall");
+ Assert.equal(wasmFrame.this, undefined);
+
+ const location = wasmFrame.where;
+ const source = await getSourceById(threadFront, location.actor);
+ Assert.equal(location.line > 0, true);
+ Assert.equal(location.column > 0, true);
+ Assert.equal(/^wasm:(?:[^:]*:)*?[0-9a-f]{16}$/.test(source.url), true);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable comma-spacing, max-len */
+ debuggee.eval(
+ "(" +
+ function () {
+ // WebAssembly bytecode was generated by running:
+ // js -e 'print(wasmTextToBinary("(module(import \"a\" \"b\")(func(export \"c\")call 0))"))'
+ const m = new WebAssembly.Module(
+ new Uint8Array([
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 132, 128, 128, 128, 0, 1, 96, 0, 0,
+ 2, 135, 128, 128, 128, 0, 1, 1, 97, 1, 98, 0, 0, 3, 130, 128, 128,
+ 128, 0, 1, 0, 6, 129, 128, 128, 128, 0, 0, 7, 133, 128, 128, 128, 0,
+ 1, 1, 99, 0, 1, 10, 138, 128, 128, 128, 0, 1, 132, 128, 128, 128, 0,
+ 0, 16, 0, 11,
+ ])
+ );
+ const i = new WebAssembly.Instance(m, {
+ a: {
+ b: () => {
+ debugger;
+ },
+ },
+ });
+ i.exports.c();
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_framearguments-01.js b/devtools/server/tests/xpcshell/test_framearguments-01.js
new file mode 100644
index 0000000000..524d43f58c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framearguments-01.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check a frame actor's arguments property.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const args = packet.frame.arguments;
+ Assert.equal(args.length, 6);
+ Assert.equal(args[0], 42);
+ Assert.equal(args[1], true);
+ Assert.equal(args[2], "nasu");
+ Assert.equal(args[3].type, "null");
+ Assert.equal(args[4].type, "undefined");
+ Assert.equal(args[5].type, "object");
+ Assert.equal(args[5].class, "Object");
+ Assert.ok(!!args[5].actor);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(number, bool, string, null_, undef, object) {
+ debugger;
+ }
+ stopMe(42, true, "nasu", null, undefined, { foo: "bar" });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_framebindings-01.js b/devtools/server/tests/xpcshell/test_framebindings-01.js
new file mode 100644
index 0000000000..ecf6f02e97
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framebindings-01.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check a frame actor's bindings property.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const environment = await packet.frame.getEnvironment();
+ const bindings = environment.bindings;
+ const args = bindings.arguments;
+ const vars = bindings.variables;
+
+ Assert.equal(args.length, 6);
+ Assert.equal(args[0].number.value, 42);
+ Assert.equal(args[1].bool.value, true);
+ Assert.equal(args[2].string.value, "nasu");
+ Assert.equal(args[3].null_.value.type, "null");
+ Assert.equal(args[4].undef.value.type, "undefined");
+ Assert.equal(args[5].object.value.type, "object");
+ Assert.equal(args[5].object.value.class, "Object");
+ Assert.ok(!!args[5].object.value.actor);
+
+ Assert.equal(vars.a.value, 1);
+ Assert.equal(vars.b.value, true);
+ Assert.equal(vars.c.value.type, "object");
+ Assert.equal(vars.c.value.class, "Object");
+ Assert.ok(!!vars.c.value.actor);
+
+ const objClient = threadFront.pauseGrip(vars.c.value);
+ const response = await objClient.getPrototypeAndProperties();
+ Assert.equal(response.ownProperties.a.configurable, true);
+ Assert.equal(response.ownProperties.a.enumerable, true);
+ Assert.equal(response.ownProperties.a.writable, true);
+ Assert.equal(response.ownProperties.a.value, "a");
+
+ Assert.equal(response.ownProperties.b.configurable, true);
+ Assert.equal(response.ownProperties.b.enumerable, true);
+ Assert.equal(response.ownProperties.b.writable, true);
+ Assert.equal(response.ownProperties.b.value.type, "undefined");
+ Assert.equal(false, "class" in response.ownProperties.b.value);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(number, bool, string, null_, undef, object) {
+ var a = 1;
+ var b = true;
+ var c = { a: "a", b: undefined };
+ debugger;
+ }
+ stopMe(42, true, "nasu", null, undefined, { foo: "bar" });
+ } +
+ ")()"
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_framebindings-02.js b/devtools/server/tests/xpcshell/test_framebindings-02.js
new file mode 100644
index 0000000000..48c243193b
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framebindings-02.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check a frame actor's parent bindings.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const environment = await packet.frame.getEnvironment();
+ let parentEnv = environment.parent;
+ const bindings = parentEnv.bindings;
+ const args = bindings.arguments;
+ const vars = bindings.variables;
+ Assert.notEqual(parentEnv, undefined);
+ Assert.equal(args.length, 0);
+ Assert.equal(vars.stopMe.value.type, "object");
+ Assert.equal(vars.stopMe.value.class, "Function");
+ Assert.ok(!!vars.stopMe.value.actor);
+
+ // Skip the global lexical scope.
+ parentEnv = parentEnv.parent.parent;
+ Assert.notEqual(parentEnv, undefined);
+ const objClient = threadFront.pauseGrip(parentEnv.object);
+ const response = await objClient.getPrototypeAndProperties();
+ Assert.equal(response.ownProperties.Object.value.getGrip().type, "object");
+ Assert.equal(
+ response.ownProperties.Object.value.getGrip().class,
+ "Function"
+ );
+ Assert.ok(!!response.ownProperties.Object.value.actorID);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(number, bool, string, null_, undef, object) {
+ var a = 1;
+ var b = true;
+ var c = { a: "a" };
+ eval("");
+ debugger;
+ }
+ stopMe(42, true, "nasu", null, undefined, { foo: "bar" });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_framebindings-03.js b/devtools/server/tests/xpcshell/test_framebindings-03.js
new file mode 100644
index 0000000000..46dc777ef1
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framebindings-03.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* strict mode code may not contain 'with' statements */
+/* eslint-disable strict */
+
+/**
+ * Check a |with| frame actor's bindings.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const env = await packet.frame.getEnvironment();
+ Assert.notEqual(env, undefined);
+
+ const parentEnv = env.parent;
+ Assert.notEqual(parentEnv, undefined);
+
+ const bindings = parentEnv.bindings;
+ const args = bindings.arguments;
+ const vars = bindings.variables;
+ Assert.equal(args.length, 1);
+ Assert.equal(args[0].number.value, 10);
+ Assert.equal(vars.r.value, 10);
+ Assert.equal(vars.a.value, Math.PI * 100);
+ Assert.equal(vars.arguments.value.class, "Arguments");
+ Assert.ok(!!vars.arguments.value.actor);
+
+ const objClient = threadFront.pauseGrip(env.object);
+ const response = await objClient.getPrototypeAndProperties();
+ Assert.equal(response.ownProperties.PI.value, Math.PI);
+ Assert.equal(response.ownProperties.cos.value.getGrip().type, "object");
+ Assert.equal(response.ownProperties.cos.value.getGrip().class, "Function");
+ Assert.ok(!!response.ownProperties.cos.value.actorID);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(number) {
+ var a;
+ var r = number;
+ with (Math) {
+ a = PI * r * r;
+ debugger;
+ }
+ }
+ stopMe(10);
+ } +
+ ")()"
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_framebindings-04.js b/devtools/server/tests/xpcshell/test_framebindings-04.js
new file mode 100644
index 0000000000..1e3cc1485c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framebindings-04.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* strict mode code may not contain 'with' statements */
+/* eslint-disable strict */
+
+/**
+ * Check the environment bindings of a |with| within a |with|.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const env = await packet.frame.getEnvironment();
+ Assert.notEqual(env, undefined);
+
+ const objClient = threadFront.pauseGrip(env.object);
+ let response = await objClient.getPrototypeAndProperties();
+ Assert.equal(response.ownProperties.one.value, 1);
+ Assert.equal(response.ownProperties.two.value, 2);
+ Assert.equal(response.ownProperties.foo, undefined);
+
+ let parentEnv = env.parent;
+ Assert.notEqual(parentEnv, undefined);
+
+ const parentClient = threadFront.pauseGrip(parentEnv.object);
+ response = await parentClient.getPrototypeAndProperties();
+ Assert.equal(response.ownProperties.PI.value, Math.PI);
+ Assert.equal(response.ownProperties.cos.value.getGrip().type, "object");
+ Assert.equal(response.ownProperties.cos.value.getGrip().class, "Function");
+ Assert.ok(!!response.ownProperties.cos.value.actorID);
+
+ parentEnv = parentEnv.parent;
+ Assert.notEqual(parentEnv, undefined);
+
+ const bindings = parentEnv.bindings;
+ const args = bindings.arguments;
+ const vars = bindings.variables;
+ Assert.equal(args.length, 1);
+ Assert.equal(args[0].number.value, 10);
+ Assert.equal(vars.r.value, 10);
+ Assert.equal(vars.a.value, Math.PI * 100);
+ Assert.equal(vars.arguments.value.class, "Arguments");
+ Assert.ok(!!vars.arguments.value.actor);
+ Assert.equal(vars.foo.value, 2 * Math.PI);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(number) {
+ var a,
+ obj = { one: 1, two: 2 };
+ var r = number;
+ with (Math) {
+ a = PI * r * r;
+ with (obj) {
+ var foo = two * PI;
+ debugger;
+ }
+ }
+ }
+ stopMe(10);
+ } +
+ ")()"
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_framebindings-05.js b/devtools/server/tests/xpcshell/test_framebindings-05.js
new file mode 100644
index 0000000000..6206fe8668
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framebindings-05.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check the environment bindings of a |with| in global scope.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const env = await packet.frame.getEnvironment();
+ Assert.notEqual(env, undefined);
+
+ const objClient = threadFront.pauseGrip(env.object);
+ let response = await objClient.getPrototypeAndProperties();
+ Assert.equal(response.ownProperties.PI.value, Math.PI);
+ Assert.equal(response.ownProperties.cos.value.getGrip().type, "object");
+ Assert.equal(response.ownProperties.cos.value.getGrip().class, "Function");
+ Assert.ok(!!response.ownProperties.cos.value.actorID);
+
+ // Skip the global lexical scope.
+ const parentEnv = env.parent.parent;
+ Assert.notEqual(parentEnv, undefined);
+
+ const parentClient = threadFront.pauseGrip(parentEnv.object);
+ response = await parentClient.getPrototypeAndProperties();
+ Assert.equal(response.ownProperties.a.value, Math.PI * 100);
+ Assert.equal(response.ownProperties.r.value, 10);
+ Assert.equal(response.ownProperties.Object.value.getGrip().type, "object");
+ Assert.equal(
+ response.ownProperties.Object.value.getGrip().class,
+ "Function"
+ );
+ Assert.ok(!!response.ownProperties.Object.value.actorID);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "var a, r = 10;\n" +
+ "with (Math) {\n" +
+ " a = PI * r * r;\n" +
+ " debugger;\n" +
+ "}"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_framebindings-06.js b/devtools/server/tests/xpcshell/test_framebindings-06.js
new file mode 100644
index 0000000000..52ab0cfe7c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framebindings-06.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const env = await packet.frame.getEnvironment();
+ equal(env.type, "function");
+ equal(env.function.displayName, "banana3");
+ let parent = env.parent;
+ equal(parent.type, "block");
+ ok("banana3" in parent.bindings.variables);
+ parent = parent.parent;
+ equal(parent.type, "function");
+ equal(parent.function.displayName, "banana2");
+ parent = parent.parent;
+ equal(parent.type, "block");
+ ok("banana2" in parent.bindings.variables);
+ parent = parent.parent;
+ equal(parent.type, "function");
+ equal(parent.function.displayName, "banana");
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "function banana(x) {\n" +
+ " return function banana2(y) {\n" +
+ " return function banana3(z) {\n" +
+ ' eval("");\n' +
+ " debugger;\n" +
+ " };\n" +
+ " };\n" +
+ "}\n" +
+ "banana('x')('y')('z');\n"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_framebindings-07.js b/devtools/server/tests/xpcshell/test_framebindings-07.js
new file mode 100644
index 0000000000..77d43dfba8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_framebindings-07.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const environment = await packet.frame.getEnvironment();
+ Assert.equal(environment.type, "function");
+ Assert.equal(environment.bindings.arguments[0].z.value, "z");
+
+ const parent = environment.parent;
+ Assert.equal(parent.type, "block");
+ Assert.equal(parent.bindings.variables.banana3.value.class, "Function");
+
+ const grandpa = parent.parent;
+ Assert.equal(grandpa.type, "function");
+ Assert.equal(grandpa.bindings.arguments[0].y.value, "y");
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ "function banana(x) {\n" +
+ " return function banana2(y) {\n" +
+ " return function banana3(z) {\n" +
+ ' eval("");\n' +
+ " debugger;\n" +
+ " };\n" +
+ " };\n" +
+ "}\n" +
+ "banana('x')('y')('z');\n"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_front_destroy.js b/devtools/server/tests/xpcshell/test_front_destroy.js
new file mode 100644
index 0000000000..33e2ac827a
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_front_destroy.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test that fronts throw errors if they are called after being destroyed.
+ */
+
+"use strict";
+
+// HACK: ServiceWorkerManager requires the "profile-change-teardown" to cleanly
+// shutdown, and setting _profileInitialized to `true` will trigger those
+// notifications (see /testing/xpcshell/head.js).
+// eslint-disable-next-line no-undef
+_profileInitialized = true;
+
+add_task(async function test() {
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ info("Create and connect the DevToolsClient");
+ const transport = DevToolsServer.connectPipe();
+ const client = new DevToolsClient(transport);
+ await client.connect();
+
+ info("Get the device front and check calling getDescription() on it");
+ const front = await client.mainRoot.getFront("device");
+ const description = await front.getDescription();
+ ok(
+ !!description,
+ "Check that the getDescription() method returns a valid response."
+ );
+
+ info("Destroy the device front and try calling getDescription again");
+ front.destroy();
+ Assert.throws(
+ () => front.getDescription(),
+ /Can not send request 'getDescription' because front 'device' is already destroyed\./,
+ "Check device front throws when getDescription() is called after destroy()"
+ );
+
+ await client.close();
+});
diff --git a/devtools/server/tests/xpcshell/test_functiongrips-01.js b/devtools/server/tests/xpcshell/test_functiongrips-01.js
new file mode 100644
index 0000000000..5abce26875
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_functiongrips-01.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ // Test named function
+ function evalCode() {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval("stopMe(stopMe)");
+ }
+
+ const packet1 = await executeOnNextTickAndWaitForPause(
+ () => evalCode(),
+ threadFront
+ );
+
+ const args1 = packet1.frame.arguments;
+
+ Assert.equal(args1[0].class, "Function");
+ Assert.equal(args1[0].name, "stopMe");
+ Assert.equal(args1[0].displayName, "stopMe");
+
+ await threadFront.resume();
+
+ // Test inferred name function
+ const packet2 = await executeOnNextTickAndWaitForPause(
+ () =>
+ debuggee.eval(
+ "var o = { m: function(foo, bar, baz) { } }; stopMe(o.m)"
+ ),
+ threadFront
+ );
+
+ const args2 = packet2.frame.arguments;
+
+ Assert.equal(args2[0].class, "Function");
+ // No name for an anonymous function, but it should have an inferred name.
+ Assert.equal(args2[0].name, undefined);
+ Assert.equal(args2[0].displayName, "m");
+
+ await threadFront.resume();
+
+ // Test anonymous function
+ const packet3 = await executeOnNextTickAndWaitForPause(
+ () => debuggee.eval("stopMe(function(foo, bar, baz) { })"),
+ threadFront
+ );
+
+ const args3 = packet3.frame.arguments;
+
+ Assert.equal(args3[0].class, "Function");
+ // No name for an anonymous function, and no inferred name, either.
+ Assert.equal(args3[0].name, undefined);
+ Assert.equal(args3[0].displayName, undefined);
+
+ await threadFront.resume();
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_getRuleText.js b/devtools/server/tests/xpcshell/test_getRuleText.js
new file mode 100644
index 0000000000..fe53dca158
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_getRuleText.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ getRuleText,
+} = require("resource://devtools/server/actors/utils/style-utils.js");
+
+const TEST_DATA = [
+ {
+ desc: "Empty input",
+ input: "",
+ line: 1,
+ column: 1,
+ throws: true,
+ },
+ {
+ desc: "Simplest test case",
+ input: "#id{color:red;background:yellow;}",
+ line: 1,
+ column: 1,
+ expected: { offset: 4, text: "color:red;background:yellow;" },
+ },
+ {
+ desc: "Multiple rules test case",
+ input:
+ "#id{color:red;background:yellow;}.class-one .class-two " +
+ "{ position:absolute; line-height: 45px}",
+ line: 1,
+ column: 34,
+ expected: { offset: 56, text: " position:absolute; line-height: 45px" },
+ },
+ {
+ desc: "Unclosed rule",
+ input: "#id{color:red;background:yellow;",
+ line: 1,
+ column: 1,
+ expected: { offset: 4, text: "color:red;background:yellow;" },
+ },
+ {
+ desc: "Null input",
+ input: null,
+ line: 1,
+ column: 1,
+ throws: true,
+ },
+ {
+ desc: "Missing loc",
+ input: "#id{color:red;background:yellow;}",
+ throws: true,
+ },
+ {
+ desc: "Multi-lines CSS",
+ input: [
+ "/* this is a multi line css */",
+ "body {",
+ " color: green;",
+ " background-repeat: no-repeat",
+ "}",
+ " /*something else here */",
+ "* {",
+ " color: purple;",
+ "}",
+ ].join("\n"),
+ line: 7,
+ column: 1,
+ expected: { offset: 116, text: "\n color: purple;\n" },
+ },
+ {
+ desc: "Multi-lines CSS and multi-line rule",
+ input: [
+ "/* ",
+ "* some comments",
+ "*/",
+ "",
+ "body {",
+ " margin: 0;",
+ " padding: 15px 15px 2px 15px;",
+ " color: red;",
+ "}",
+ "",
+ "#header .btn, #header .txt {",
+ " font-size: 100%;",
+ "}",
+ "",
+ "#header #information {",
+ " color: #dddddd;",
+ " font-size: small;",
+ "}",
+ ].join("\n"),
+ line: 5,
+ column: 1,
+ expected: {
+ offset: 30,
+ text: "\n margin: 0;\n padding: 15px 15px 2px 15px;\n color: red;\n",
+ },
+ },
+ {
+ desc: "Content string containing a } character",
+ input: " #id{border:1px solid red;content: '}';color:red;}",
+ line: 1,
+ column: 4,
+ expected: {
+ offset: 7,
+ text: "border:1px solid red;content: '}';color:red;",
+ },
+ },
+ {
+ desc: "Rule contains no tokens",
+ input: "div{}",
+ line: 1,
+ column: 1,
+ expected: { offset: 4, text: "" },
+ },
+];
+
+function run_test() {
+ for (const test of TEST_DATA) {
+ info("Starting test: " + test.desc);
+ info("Input string " + test.input);
+ let output;
+ try {
+ output = getRuleText(test.input, test.line, test.column);
+ if (test.throws) {
+ info("Test should have thrown");
+ Assert.ok(false);
+ }
+ } catch (e) {
+ info("getRuleText threw an exception with the given input string");
+ if (test.throws) {
+ info("Exception expected");
+ Assert.ok(true);
+ } else {
+ info("Exception unexpected\n" + e);
+ Assert.ok(false);
+ }
+ }
+ if (output) {
+ deepEqual(output, test.expected);
+ }
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_getTextAtLineColumn.js b/devtools/server/tests/xpcshell/test_getTextAtLineColumn.js
new file mode 100644
index 0000000000..3aa9915192
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_getTextAtLineColumn.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ getTextAtLineColumn,
+} = require("resource://devtools/server/actors/utils/style-utils.js");
+
+const TEST_DATA = [
+ {
+ desc: "simplest",
+ input: "#id{color:red;background:yellow;}",
+ line: 1,
+ column: 5,
+ expected: { offset: 4, text: "color:red;background:yellow;}" },
+ },
+ {
+ desc: "multiple lines",
+ input: "one\n two\n three",
+ line: 3,
+ column: 3,
+ expected: { offset: 11, text: "three" },
+ },
+];
+
+function run_test() {
+ for (const test of TEST_DATA) {
+ info("Starting test: " + test.desc);
+ info("Input string " + test.input);
+
+ const output = getTextAtLineColumn(test.input, test.line, test.column);
+ deepEqual(output, test.expected);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_get_command_and_arg.js b/devtools/server/tests/xpcshell/test_get_command_and_arg.js
new file mode 100644
index 0000000000..b3f0ab8ec4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_get_command_and_arg.js
@@ -0,0 +1,121 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ getCommandAndArgs,
+} = require("resource://devtools/server/actors/webconsole/commands/parser.js");
+
+const testcases = [
+ { input: ":help", expectedOutput: "help()" },
+ {
+ input: ":screenshot --fullscreen",
+ expectedOutput: 'screenshot({"fullscreen":true})',
+ },
+ {
+ input: ":screenshot --fullscreen true",
+ expectedOutput: 'screenshot({"fullscreen":true})',
+ },
+ { input: ":screenshot ", expectedOutput: "screenshot()" },
+ {
+ input: ":screenshot --dpr 0.5 --fullpage --chrome",
+ expectedOutput: 'screenshot({"dpr":0.5,"fullpage":true,"chrome":true})',
+ },
+ {
+ input: ":screenshot 'filename'",
+ expectedOutput: 'screenshot({"filename":"filename"})',
+ },
+ {
+ input: ":screenshot filename",
+ expectedOutput: 'screenshot({"filename":"filename"})',
+ },
+ {
+ input:
+ ":screenshot --name 'filename' --name `filename` --name \"filename\"",
+ expectedOutput: 'screenshot({"name":["filename","filename","filename"]})',
+ },
+ {
+ input: ":screenshot 'filename1' 'filename2' 'filename3'",
+ expectedOutput: 'screenshot({"filename":"filename1"})',
+ },
+ {
+ input: ":screenshot --chrome --chrome",
+ expectedOutput: 'screenshot({"chrome":true})',
+ },
+ {
+ input: ':screenshot "file name with spaces"',
+ expectedOutput: 'screenshot({"filename":"file name with spaces"})',
+ },
+ {
+ input: ":screenshot 'filename1' --name 'filename2'",
+ expectedOutput: 'screenshot({"filename":"filename1","name":"filename2"})',
+ },
+ {
+ input: ":screenshot --name 'filename1' 'filename2'",
+ expectedOutput: 'screenshot({"name":"filename1","filename":"filename2"})',
+ },
+ {
+ input: ':screenshot "fo\\"o bar"',
+ expectedOutput: 'screenshot({"filename":"fo\\\\\\"o bar"})',
+ },
+ {
+ input: ':screenshot "foo b\\"ar"',
+ expectedOutput: 'screenshot({"filename":"foo b\\\\\\"ar"})',
+ },
+];
+
+const edgecases = [
+ { input: ":", expectedError: /Missing a command name after ':'/ },
+ { input: ":invalid", expectedError: /'invalid' is not a valid command/ },
+ {
+ input: ":screenshot :help",
+ expectedError:
+ /Executing multiple commands in one evaluation is not supported/,
+ },
+ { input: ":screenshot --", expectedError: /invalid flag/ },
+ {
+ input: ':screenshot "fo"o bar',
+ expectedError:
+ /String has unescaped `"` in \["fo"o\.\.\.\], may miss a space between arguments/,
+ },
+ {
+ input: ':screenshot "foo b"ar',
+ expectedError:
+ // eslint-disable-next-line max-len
+ /String has unescaped `"` in \["foo b"ar\.\.\.\], may miss a space between arguments/,
+ },
+ { input: ": screenshot", expectedError: /Missing a command name after ':'/ },
+ {
+ input: ':screenshot "file name',
+ expectedError: /String does not terminate/,
+ },
+ {
+ input: ':screenshot "file name --clipboard',
+ expectedError: /String does not terminate before flag "clipboard"/,
+ },
+ {
+ input: "::screenshot",
+ expectedError: /':screenshot' is not a valid command/,
+ },
+];
+
+function formatArgs(args) {
+ return Object.keys(args).length ? JSON.stringify(args) : "";
+}
+
+function run_test() {
+ testcases.forEach(testcase => {
+ const { command, args } = getCommandAndArgs(testcase.input);
+ const argsString = formatArgs(args);
+ Assert.equal(`${command}(${argsString})`, testcase.expectedOutput);
+ });
+
+ edgecases.forEach(testcase => {
+ Assert.throws(
+ () => getCommandAndArgs(testcase.input),
+ testcase.expectedError,
+ `"${testcase.input}" should throw expected error`
+ );
+ });
+}
diff --git a/devtools/server/tests/xpcshell/test_getyoungestframe.js b/devtools/server/tests/xpcshell/test_getyoungestframe.js
new file mode 100644
index 0000000000..f08628b7ed
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_getyoungestframe.js
@@ -0,0 +1,38 @@
+/* eslint-disable strict */
+function run_test() {
+ Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+ });
+ const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+ );
+ addDebuggerToGlobal(globalThis);
+ const xpcInspector = Cc["@mozilla.org/jsinspector;1"].getService(
+ Ci.nsIJSInspector
+ );
+ const g = createTestGlobal("test1");
+
+ const dbg = makeDebugger();
+ dbg.uncaughtExceptionHook = testExceptionHook;
+
+ dbg.addDebuggee(g);
+ dbg.onDebuggerStatement = function (frame) {
+ Assert.ok(frame === dbg.getNewestFrame());
+ // Execute from the nested event loop, dbg.getNewestFrame() won't
+ // be working anymore.
+
+ executeSoon(function () {
+ try {
+ Assert.ok(frame === dbg.getNewestFrame());
+ } finally {
+ xpcInspector.exitNestedEventLoop("test");
+ }
+ });
+ xpcInspector.enterNestedEventLoop("test");
+ };
+
+ g.eval("function debuggerStatement() { debugger; }; debuggerStatement();");
+
+ dbg.disable();
+}
diff --git a/devtools/server/tests/xpcshell/test_ignore_caught_exceptions.js b/devtools/server/tests/xpcshell/test_ignore_caught_exceptions.js
new file mode 100644
index 0000000000..fe04161aab
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_ignore_caught_exceptions.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that setting ignoreCaughtExceptions will cause the debugger to ignore
+ * caught exceptions, but not uncaught ones.
+ */
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, commands }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: true,
+ });
+ await resume(threadFront);
+ const paused = await waitForPause(threadFront);
+ Assert.equal(paused.why.type, "exception");
+ equal(paused.frame.where.line, 6, "paused at throw");
+
+ await resume(threadFront);
+ },
+ {
+ // Bug 1508289, exception tests fails in worker scope
+ doNotRunWorker: true,
+ }
+ )
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ try {
+ Cu.evalInSandbox(` // 1
+ debugger; // 2
+ try { // 3
+ throw "foo"; // 4
+ } catch (e) {} // 5
+ throw "bar"; // 6
+ `, // 7
+ debuggee,
+ "1.8",
+ "test_pause_exceptions-03.js",
+ 1
+ );
+ } catch (e) {}
+}
diff --git a/devtools/server/tests/xpcshell/test_ignore_no_interface_exceptions.js b/devtools/server/tests/xpcshell/test_ignore_no_interface_exceptions.js
new file mode 100644
index 0000000000..50d28ffdc0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_ignore_no_interface_exceptions.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that the debugger automatically ignores NS_ERROR_NO_INTERFACE
+ * exceptions, but not normal ones.
+ */
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ await threadFront.pauseOnExceptions(true, false);
+ const paused = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ equal(paused.frame.where.line, 6, "paused at throw");
+
+ await resume(threadFront);
+ },
+ {
+ // Bug 1508289, exception tests fails in worker scope
+ doNotRunWorker: true,
+ }
+ )
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(` // 1
+ function QueryInterface() { // 2
+ throw Cr.NS_ERROR_NO_INTERFACE; // 3
+ } // 4
+ function stopMe() { // 5
+ throw 42; // 6
+ } // 7
+ try { // 8
+ QueryInterface(); // 9
+ } catch (e) {} // 10
+ try { // 11
+ stopMe(); // 12
+ } catch (e) {}`, // 13
+ debuggee,
+ "1.8",
+ "test_ignore_no_interface_exceptions.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_interrupt.js b/devtools/server/tests/xpcshell/test_interrupt.js
new file mode 100644
index 0000000000..07593a7360
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_interrupt.js
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client, targetFront }) => {
+ const onPaused = waitForEvent(threadFront, "paused");
+ await threadFront.interrupt();
+ await onPaused;
+ Assert.equal(threadFront.paused, true);
+ await threadFront.resume();
+ Assert.equal(threadFront.paused, false);
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_layout-reflows-observer.js b/devtools/server/tests/xpcshell/test_layout-reflows-observer.js
new file mode 100644
index 0000000000..74f31b97fe
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_layout-reflows-observer.js
@@ -0,0 +1,311 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the LayoutChangesObserver
+
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+var {
+ getLayoutChangesObserver,
+ releaseLayoutChangesObserver,
+ LayoutChangesObserver,
+} = require("resource://devtools/server/actors/reflow.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+// Override set/clearTimeout on LayoutChangesObserver to avoid depending on
+// time in this unit test. This means that LayoutChangesObserver.eventLoopTimer
+// will be the timeout callback instead of the timeout itself, so test cases
+// will need to execute it to fake a timeout
+LayoutChangesObserver.prototype._setTimeout = cb => cb;
+LayoutChangesObserver.prototype._clearTimeout = function () {};
+
+// Mock the targetActor since we only really want to test the LayoutChangesObserver
+// and don't want to depend on a window object, nor want to test protocol.js
+class MockTargetActor extends EventEmitter {
+ constructor() {
+ super();
+ this.docShell = new MockDocShell();
+ this.window = new MockWindow(this.docShell);
+ this.windows = [this.window];
+ this.attached = true;
+ }
+
+ get chromeEventHandler() {
+ return this.docShell.chromeEventHandler;
+ }
+
+ isDestroyed() {
+ return false;
+ }
+}
+
+function MockWindow(docShell) {
+ this.docShell = docShell;
+}
+MockWindow.prototype = {
+ QueryInterface() {
+ const self = this;
+ return {
+ getInterface() {
+ return {
+ QueryInterface() {
+ return self.docShell;
+ },
+ };
+ },
+ };
+ },
+ setTimeout(cb) {
+ // Simply return the cb itself so that we can execute it in the test instead
+ // of depending on a real timeout
+ return cb;
+ },
+ clearTimeout() {},
+};
+
+function MockDocShell() {
+ this.observer = null;
+}
+MockDocShell.prototype = {
+ addWeakReflowObserver(observer) {
+ this.observer = observer;
+ },
+ removeWeakReflowObserver() {},
+ get chromeEventHandler() {
+ return {
+ addEventListener: (type, cb) => {
+ if (type === "resize") {
+ this.resizeCb = cb;
+ }
+ },
+ removeEventListener: (type, cb) => {
+ if (type === "resize" && cb === this.resizeCb) {
+ this.resizeCb = null;
+ }
+ },
+ };
+ },
+ mockResize() {
+ if (this.resizeCb) {
+ this.resizeCb();
+ }
+ },
+};
+
+function run_test() {
+ instancesOfObserversAreSharedBetweenWindows();
+ eventsAreBatched();
+ noEventsAreSentWhenThereAreNoReflowsAndLoopTimeouts();
+ observerIsAlreadyStarted();
+ destroyStopsObserving();
+ stoppingAndStartingSeveralTimesWorksCorrectly();
+ reflowsArentStackedWhenStopped();
+ stackedReflowsAreResetOnStop();
+}
+
+function instancesOfObserversAreSharedBetweenWindows() {
+ info(
+ "Checking that when requesting twice an instances of the observer " +
+ "for the same WindowGlobalTargetActor, the instance is shared"
+ );
+
+ info("Checking 2 instances of the observer for the targetActor 1");
+ const targetActor1 = new MockTargetActor();
+ const obs11 = getLayoutChangesObserver(targetActor1);
+ const obs12 = getLayoutChangesObserver(targetActor1);
+ Assert.equal(obs11, obs12);
+
+ info("Checking 2 instances of the observer for the targetActor 2");
+ const targetActor2 = new MockTargetActor();
+ const obs21 = getLayoutChangesObserver(targetActor2);
+ const obs22 = getLayoutChangesObserver(targetActor2);
+ Assert.equal(obs21, obs22);
+
+ info(
+ "Checking that observers instances for 2 different targetActors are " +
+ "different"
+ );
+ Assert.notEqual(obs11, obs21);
+
+ releaseLayoutChangesObserver(targetActor1);
+ releaseLayoutChangesObserver(targetActor1);
+ releaseLayoutChangesObserver(targetActor2);
+ releaseLayoutChangesObserver(targetActor2);
+}
+
+function eventsAreBatched() {
+ info(
+ "Checking that reflow events are batched and only sent when the " +
+ "timeout expires"
+ );
+
+ // Note that in this test, we mock the target actor and its window property, so we also
+ // mock the setTimeout/clearTimeout mechanism and just call the callback manually
+ const targetActor = new MockTargetActor();
+ const observer = getLayoutChangesObserver(targetActor);
+
+ const reflowsEvents = [];
+ const onReflows = reflows => reflowsEvents.push(reflows);
+ observer.on("reflows", onReflows);
+
+ const resizeEvents = [];
+ const onResize = () => resizeEvents.push("resize");
+ observer.on("resize", onResize);
+
+ info("Fake one reflow event");
+ targetActor.window.docShell.observer.reflow();
+ info("Checking that no batched reflow event has been emitted");
+ Assert.equal(reflowsEvents.length, 0);
+
+ info("Fake another reflow event");
+ targetActor.window.docShell.observer.reflow();
+ info("Checking that still no batched reflow event has been emitted");
+ Assert.equal(reflowsEvents.length, 0);
+
+ info("Fake a few of resize events too");
+ targetActor.window.docShell.mockResize();
+ targetActor.window.docShell.mockResize();
+ targetActor.window.docShell.mockResize();
+ info("Checking that still no batched resize event has been emitted");
+ Assert.equal(resizeEvents.length, 0);
+
+ info("Faking timeout expiration and checking that events are sent");
+ observer.eventLoopTimer();
+ Assert.equal(reflowsEvents.length, 1);
+ Assert.equal(reflowsEvents[0].length, 2);
+ Assert.equal(resizeEvents.length, 1);
+
+ observer.off("reflows", onReflows);
+ observer.off("resize", onResize);
+ releaseLayoutChangesObserver(targetActor);
+}
+
+function noEventsAreSentWhenThereAreNoReflowsAndLoopTimeouts() {
+ info(
+ "Checking that if no reflows were detected and the event batching " +
+ "loop expires, then no reflows event is sent"
+ );
+
+ const targetActor = new MockTargetActor();
+ const observer = getLayoutChangesObserver(targetActor);
+
+ const reflowsEvents = [];
+ const onReflows = reflows => reflowsEvents.push(reflows);
+ observer.on("reflows", onReflows);
+
+ info("Faking timeout expiration and checking for reflows");
+ observer.eventLoopTimer();
+ Assert.equal(reflowsEvents.length, 0);
+
+ observer.off("reflows", onReflows);
+ releaseLayoutChangesObserver(targetActor);
+}
+
+function observerIsAlreadyStarted() {
+ info("Checking that the observer is already started when getting it");
+
+ const targetActor = new MockTargetActor();
+ const observer = getLayoutChangesObserver(targetActor);
+ Assert.ok(observer.isObserving);
+
+ observer.stop();
+ Assert.ok(!observer.isObserving);
+
+ observer.start();
+ Assert.ok(observer.isObserving);
+
+ releaseLayoutChangesObserver(targetActor);
+}
+
+function destroyStopsObserving() {
+ info("Checking that the destroying the observer stops it");
+
+ const targetActor = new MockTargetActor();
+ const observer = getLayoutChangesObserver(targetActor);
+ Assert.ok(observer.isObserving);
+
+ observer.destroy();
+ Assert.ok(!observer.isObserving);
+
+ releaseLayoutChangesObserver(targetActor);
+}
+
+function stoppingAndStartingSeveralTimesWorksCorrectly() {
+ info(
+ "Checking that the stopping and starting several times the observer" +
+ " works correctly"
+ );
+
+ const targetActor = new MockTargetActor();
+ const observer = getLayoutChangesObserver(targetActor);
+
+ Assert.ok(observer.isObserving);
+ observer.start();
+ observer.start();
+ observer.start();
+ Assert.ok(observer.isObserving);
+
+ observer.stop();
+ Assert.ok(!observer.isObserving);
+
+ observer.stop();
+ observer.stop();
+ Assert.ok(!observer.isObserving);
+
+ releaseLayoutChangesObserver(targetActor);
+}
+
+function reflowsArentStackedWhenStopped() {
+ info("Checking that when stopped, reflows aren't stacked in the observer");
+
+ const targetActor = new MockTargetActor();
+ const observer = getLayoutChangesObserver(targetActor);
+
+ info("Stoping the observer");
+ observer.stop();
+
+ info("Faking reflows");
+ targetActor.window.docShell.observer.reflow();
+ targetActor.window.docShell.observer.reflow();
+ targetActor.window.docShell.observer.reflow();
+
+ info("Checking that reflows aren't recorded");
+ Assert.equal(observer.reflows.length, 0);
+
+ info("Starting the observer and faking more reflows");
+ observer.start();
+ targetActor.window.docShell.observer.reflow();
+ targetActor.window.docShell.observer.reflow();
+ targetActor.window.docShell.observer.reflow();
+
+ info("Checking that reflows are recorded");
+ Assert.equal(observer.reflows.length, 3);
+
+ releaseLayoutChangesObserver(targetActor);
+}
+
+function stackedReflowsAreResetOnStop() {
+ info("Checking that stacked reflows are reset on stop");
+
+ const targetActor = new MockTargetActor();
+ const observer = getLayoutChangesObserver(targetActor);
+
+ targetActor.window.docShell.observer.reflow();
+ Assert.equal(observer.reflows.length, 1);
+
+ observer.stop();
+ Assert.equal(observer.reflows.length, 0);
+
+ targetActor.window.docShell.observer.reflow();
+ Assert.equal(observer.reflows.length, 0);
+
+ observer.start();
+ Assert.equal(observer.reflows.length, 0);
+
+ targetActor.window.docShell.observer.reflow();
+ Assert.equal(observer.reflows.length, 1);
+
+ releaseLayoutChangesObserver(targetActor);
+}
diff --git a/devtools/server/tests/xpcshell/test_listsources-01.js b/devtools/server/tests/xpcshell/test_listsources-01.js
new file mode 100644
index 0000000000..306825278c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_listsources-01.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check basic getSources functionality.
+ */
+
+var gNumTimesSourcesSent = 0;
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ client.request = (function (origRequest) {
+ return function (request, onResponse) {
+ if (request.type === "sources") {
+ ++gNumTimesSourcesSent;
+ }
+ return origRequest.call(this, request, onResponse);
+ };
+ })(client.request);
+
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const response = await threadFront.getSources();
+
+ Assert.ok(
+ response.sources.some(function (s) {
+ return s.url && s.url.match(/test_listsources-01.js/);
+ })
+ );
+
+ Assert.ok(
+ gNumTimesSourcesSent <= 1,
+ "Should only send one sources request at most, even though we" +
+ " might have had to send one to determine feature support."
+ );
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ "var line0 = Error().lineNumber;\n" +
+ "debugger;\n" + // line0 + 1
+ "var a = 1;\n" + // line0 + 2
+ "var b = 2;\n", // line0 + 3
+ debuggee
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_listsources-02.js b/devtools/server/tests/xpcshell/test_listsources-02.js
new file mode 100644
index 0000000000..a2f9cc3bda
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_listsources-02.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check getting sources before there are any.
+ */
+
+var gNumTimesSourcesSent = 0;
+
+add_task(
+ threadFrontTest(async ({ threadFront, client }) => {
+ client.request = (function (origRequest) {
+ return function (request, onResponse) {
+ if (request.type === "sources") {
+ ++gNumTimesSourcesSent;
+ }
+ return origRequest.call(this, request, onResponse);
+ };
+ })(client.request);
+
+ // Test listing zero sources
+ const packet = await threadFront.getSources();
+
+ Assert.ok(!packet.error);
+ Assert.ok(!!packet.sources);
+ Assert.equal(packet.sources.length, 0);
+
+ Assert.ok(
+ gNumTimesSourcesSent <= 1,
+ "Should only send one sources request at most, even though we" +
+ " might have had to send one to determine feature support."
+ );
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_listsources-03.js b/devtools/server/tests/xpcshell/test_listsources-03.js
new file mode 100644
index 0000000000..f8af5aca6e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_listsources-03.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check getSources functionality when there are lots of sources.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const response = await threadFront.getSources();
+
+ Assert.ok(
+ !response.error,
+ "There shouldn't be an error fetching large amounts of sources."
+ );
+
+ Assert.ok(
+ response.sources.some(function (s) {
+ return s.url.match(/foo-999.js$/);
+ })
+ );
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ for (let i = 0; i < 1000; i++) {
+ Cu.evalInSandbox(
+ "function foo###() {return ###;}".replace(/###/g, i),
+ debuggee,
+ "1.8",
+ "http://example.com/foo-" + i + ".js",
+ 1
+ );
+ }
+ debuggee.eval("debugger;");
+}
diff --git a/devtools/server/tests/xpcshell/test_logpoint-01.js b/devtools/server/tests/xpcshell/test_logpoint-01.js
new file mode 100644
index 0000000000..a5cb4f2197
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_logpoint-01.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that logpoints generate console messages.
+ */
+
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+
+add_task(
+ threadFrontTest(async ({ threadActor, threadFront, debuggee, client }) => {
+ let lastMessage, lastExpression;
+ const targetActor = threadActor._parent;
+ // Only Workers are evaluating through the WebConsoleActor.
+ // Tabs will be evaluating directly via the frame object.
+ targetActor._consoleActor = {
+ evaluateJS(expression) {
+ lastExpression = expression;
+ },
+ };
+
+ // And then listen for resource RDP event.
+ // Bug 1646677: But we should probably migrate this test to ResourceCommand so that
+ // we don't have to hack the server side via Resource.watchResources call.
+ targetActor.on("resource-available-form", resources => {
+ if (resources[0].resourceType == Resources.TYPES.CONSOLE_MESSAGE) {
+ lastMessage = resources[0].message;
+ }
+ });
+
+ // But both tabs and processes will be going through the ConsoleMessages module
+ // We force watching for console message first,
+ await Resources.watchResources(targetActor, [
+ Resources.TYPES.CONSOLE_MESSAGE,
+ ]);
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+
+ // Set a logpoint which should invoke console.log.
+ threadFront.setBreakpoint(
+ {
+ sourceUrl: source.url,
+ line: 3,
+ },
+ { logValue: "a" }
+ );
+ await client.waitForRequestsToSettle();
+
+ // Execute the rest of the code.
+ await threadFront.resume();
+
+ // NOTE: logpoints evaluated in a worker have a lastExpression
+ if (lastMessage) {
+ Assert.equal(lastMessage.level, "logPoint");
+ Assert.equal(lastMessage.arguments[0], "three");
+ Assert.ok(/\d+\.\d+/.test(lastMessage.timeStamp));
+ } else {
+ Assert.equal(lastExpression.text, "console.log(...[a])");
+ Assert.equal(lastExpression.lineNumber, 3);
+ }
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ "debugger;\n" + // 1
+ "var a = 'three';\n" + // 2
+ "var b = 2;\n", // 3
+ debuggee,
+ "1.8",
+ "test.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_logpoint-02.js b/devtools/server/tests/xpcshell/test_logpoint-02.js
new file mode 100644
index 0000000000..d84d3fc324
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_logpoint-02.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that conditions are respected when specified in a logpoint.
+ */
+
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+
+add_task(
+ threadFrontTest(async ({ threadActor, threadFront, debuggee, client }) => {
+ let lastMessage, lastExpression;
+ const targetActor = threadActor._parent;
+ // Only Workers are evaluating through the WebConsoleActor.
+ // Tabs will be evaluating directly via the frame object.
+ targetActor._consoleActor = {
+ evaluateJS(expression) {
+ lastExpression = expression;
+ },
+ };
+
+ // And then listen for resource RDP event.
+ // Bug 1646677: But we should probably migrate this test to ResourceCommand so that
+ // we don't have to hack the server side via Resource.watchResources call.
+ targetActor.on("resource-available-form", resources => {
+ if (resources[0].resourceType == Resources.TYPES.CONSOLE_MESSAGE) {
+ lastMessage = resources[0].message;
+ }
+ });
+
+ // But both tabs and processes will be going through the ConsoleMessages module
+ // We force watching for console message first,
+ await Resources.watchResources(targetActor, [
+ Resources.TYPES.CONSOLE_MESSAGE,
+ ]);
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+
+ // Set a logpoint which should invoke console.log.
+ threadFront.setBreakpoint(
+ {
+ sourceUrl: source.url,
+ line: 4,
+ },
+ { logValue: "a", condition: "a === 5" }
+ );
+ await client.waitForRequestsToSettle();
+
+ // Execute the rest of the code.
+ await threadFront.resume();
+
+ // NOTE: logpoints evaluated in a worker have a lastExpression
+ if (lastMessage) {
+ Assert.equal(lastMessage.level, "logPoint");
+ Assert.equal(lastMessage.arguments[0], 5);
+ Assert.ok(/\d+\.\d+/.test(lastMessage.timeStamp));
+ } else {
+ Assert.equal(lastExpression.text, "console.log(...[a])");
+ Assert.equal(lastExpression.lineNumber, 4);
+ }
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ "debugger;\n" + // 1
+ "var a = 1;\n" + // 2
+ "while (a < 10) {\n" + // 3
+ " a++;\n" + // 4
+ "}",
+ debuggee,
+ "1.8",
+ "test.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_logpoint-03.js b/devtools/server/tests/xpcshell/test_logpoint-03.js
new file mode 100644
index 0000000000..b5d4440889
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_logpoint-03.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that logpoints generate console errors if the logpoint statement is invalid.
+ */
+
+const Resources = require("resource://devtools/server/actors/resources/index.js");
+
+add_task(
+ threadFrontTest(async ({ threadActor, threadFront, debuggee, client }) => {
+ let lastMessage, lastExpression;
+ const targetActor = threadActor._parent;
+ // Only Workers are evaluating through the WebConsoleActor.
+ // Tabs will be evaluating directly via the frame object.
+ targetActor._consoleActor = {
+ evaluateJS(expression) {
+ lastExpression = expression;
+ },
+ };
+
+ // And then listen for resource RDP event.
+ // Bug 1646677: But we should probably migrate this test to ResourceCommand so that
+ // we don't have to hack the server side via Resource.watchResources call.
+ targetActor.on("resource-available-form", resources => {
+ if (resources[0].resourceType == Resources.TYPES.CONSOLE_MESSAGE) {
+ lastMessage = resources[0].message;
+ }
+ });
+
+ // But both tabs and processes will be going through the ConsoleMessages module
+ // We force watching for console message first,
+ await Resources.watchResources(targetActor, [
+ Resources.TYPES.CONSOLE_MESSAGE,
+ ]);
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+
+ // Set a logpoint which should throw an error message.
+ await threadFront.setBreakpoint(
+ {
+ sourceUrl: source.url,
+ line: 3,
+ },
+ { logValue: "c" }
+ );
+
+ // Execute the rest of the code.
+ await threadFront.resume();
+
+ // NOTE: logpoints evaluated in a worker have a lastExpression
+ if (lastMessage) {
+ Assert.equal(lastMessage.level, "logPointError");
+ Assert.equal(lastMessage.arguments[0], "c is not defined");
+ Assert.ok(/\d+\.\d+/.test(lastMessage.timeStamp));
+ } else {
+ Assert.equal(lastExpression.text, "console.log(...[c])");
+ Assert.equal(lastExpression.lineNumber, 3);
+ }
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ "debugger;\n" + // 1
+ "var a = 'three';\n" + // 2
+ "var b = 2;\n", // 3
+ debuggee,
+ "1.8",
+ "test.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_longstringgrips-01.js b/devtools/server/tests/xpcshell/test_longstringgrips-01.js
new file mode 100644
index 0000000000..ac0b228c17
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_longstringgrips-01.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var gDebuggee;
+var gClient;
+var gThreadFront;
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, client }) => {
+ gThreadFront = threadFront;
+ gDebuggee = debuggee;
+ gClient = client;
+ test_longstring_grip();
+ },
+ { waitForFinish: true }
+ )
+);
+
+function test_longstring_grip() {
+ const longString =
+ "All I want is to be a monkey of moderate intelligence who" +
+ " wears a suit... that's why I'm transferring to business school! Maybe I" +
+ " love you so much, I love you no matter who you are pretending to be." +
+ " Enough about your promiscuous mother, Hermes! We have bigger problems." +
+ " For example, if you killed your grandfather, you'd cease to exist! What" +
+ " kind of a father would I be if I said no? Yep, I remember. They came in" +
+ " last at the Olympics, then retired to promote alcoholic beverages! And" +
+ " remember, don't do anything that affects anything, unless it turns out" +
+ " you were supposed to, in which case, for the love of God, don't not do" +
+ " it!";
+
+ DevToolsServer.LONG_STRING_LENGTH = 200;
+
+ gThreadFront.once("paused", function (packet) {
+ const args = packet.frame.arguments;
+ Assert.equal(args.length, 1);
+ const grip = args[0];
+
+ try {
+ Assert.equal(grip.type, "longString");
+ Assert.equal(grip.length, longString.length);
+ Assert.equal(
+ grip.initial,
+ longString.substr(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH)
+ );
+
+ const longStringFront = createLongStringFront(gClient, grip);
+ longStringFront.substring(22, 28).then(function (response) {
+ try {
+ Assert.equal(response, "monkey");
+ } finally {
+ gThreadFront.resume().then(function () {
+ finishClient(gClient);
+ });
+ }
+ });
+ } catch (error) {
+ gThreadFront.resume().then(function () {
+ finishClient(gClient);
+ do_throw(error);
+ });
+ }
+ });
+
+ gDebuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ gDebuggee.eval('stopMe("' + longString + '")');
+}
diff --git a/devtools/server/tests/xpcshell/test_nativewrappers.js b/devtools/server/tests/xpcshell/test_nativewrappers.js
new file mode 100644
index 0000000000..170a2a1e6e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_nativewrappers.js
@@ -0,0 +1,39 @@
+/* eslint-disable strict */
+function run_test() {
+ Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+ });
+ const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+ );
+ addDebuggerToGlobal(globalThis);
+ const g = createTestGlobal("test1");
+
+ const dbg = makeDebugger();
+ dbg.addDebuggee(g);
+ dbg.onDebuggerStatement = function (frame) {
+ const args = frame.arguments;
+ try {
+ args[0];
+ Assert.ok(true);
+ } catch (ex) {
+ Assert.ok(false);
+ }
+ };
+
+ g.eval("function stopMe(arg) {debugger;}");
+
+ const g2 = createTestGlobal("test2");
+ g2.g = g;
+ // Not using the "stringify a function" trick because that runs afoul of the
+ // Cu.importGlobalProperties lint and we don't need it here anyway.
+ g2.eval(`(function createBadEvent() {
+ Cu.importGlobalProperties(["DOMParser"]);
+ let parser = new DOMParser();
+ let doc = parser.parseFromString("<foo></foo>", "text/xml");
+ g.stopMe(doc.createEvent("MouseEvent"));
+ } )()`);
+
+ dbg.disable();
+}
diff --git a/devtools/server/tests/xpcshell/test_nesting-03.js b/devtools/server/tests/xpcshell/test_nesting-03.js
new file mode 100644
index 0000000000..0a64e751cd
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_nesting-03.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that we can detect nested event loops in tabs with the same URL.
+
+add_task(async function () {
+ const GLOBAL_NAME = "test-nesting1";
+
+ initTestDevToolsServer();
+ addTestGlobal(GLOBAL_NAME);
+ addTestGlobal(GLOBAL_NAME);
+
+ // Connect two thread actors, debugging the same debuggee, and both being paused.
+ const firstClient = new DevToolsClient(DevToolsServer.connectPipe());
+ await firstClient.connect();
+ const { threadFront: firstThreadFront } = await attachTestThread(
+ firstClient,
+ GLOBAL_NAME
+ );
+ await firstThreadFront.interrupt();
+
+ const secondClient = new DevToolsClient(DevToolsServer.connectPipe());
+ await secondClient.connect();
+ const { threadFront: secondThreadFront } = await attachTestThread(
+ secondClient,
+ GLOBAL_NAME
+ );
+ await secondThreadFront.interrupt();
+
+ // Then check how concurrent resume work
+ let result;
+ try {
+ result = await firstThreadFront.resume();
+ } catch (e) {
+ Assert.ok(e.includes("wrongOrder"), "rejects with the wrong order");
+ }
+ Assert.ok(!result, "no response");
+
+ result = await secondThreadFront.resume();
+ Assert.ok(true, "resumed as expected");
+
+ await firstThreadFront.resume();
+
+ Assert.ok(true, "resumed as expected");
+ await firstClient.close();
+
+ await finishClient(secondClient);
+});
diff --git a/devtools/server/tests/xpcshell/test_nesting-04.js b/devtools/server/tests/xpcshell/test_nesting-04.js
new file mode 100644
index 0000000000..dcee257c40
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_nesting-04.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Verify that we never pause while being already paused.
+ * i.e. we don't support more than one nested event loops.
+ */
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront, debuggee }) => {
+ await threadFront.setBreakpoint({ sourceUrl: "nesting-04.js", line: 2 });
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ Assert.equal(packet.frame.where.line, 5);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ info("Test calling interrupt");
+ const onPaused = waitForPause(threadFront);
+ await threadFront.interrupt();
+ // interrupt() doesn't return anything, but bailout while emitting a paused packet
+ // But we don't pause again, the reason prove it so
+ const paused = await onPaused;
+ equal(paused.why.type, "alreadyPaused");
+
+ info("Test by evaluating code via the console");
+ const { result } = await commands.scriptCommand.execute(
+ "debugger; functionWithDebuggerStatement()",
+ {
+ frameActor: packet.frame.actorID,
+ }
+ );
+ // The fact that it returned immediately means that we did not pause
+ equal(result, 42);
+
+ info("Test by calling code from chrome context");
+ // This should be equivalent to any actor somehow triggering some page's JS
+ const rv = debuggee.functionWithDebuggerStatement();
+ // The fact that it returned immediately means that we did not pause
+ equal(rv, 42);
+
+ info("Test by stepping over a function that breaks");
+ // This will only step over the debugger; statement we just break on
+ const step1 = await stepOver(threadFront);
+ equal(step1.why.type, "resumeLimit");
+ equal(step1.frame.where.line, 6);
+
+ // stepOver will actually resume and re-pause on the breakpoint
+ const step2 = await stepOver(threadFront);
+ equal(step2.why.type, "breakpoint");
+ equal(step2.frame.where.line, 2);
+
+ // Sanity check to ensure that the functionWithDebuggerStatement really pauses
+ info("Resume and pause on the breakpoint");
+ const pausedPacket = await resumeAndWaitForPause(threadFront);
+ Assert.equal(pausedPacket.frame.where.line, 2);
+ // The breakpoint takes over the debugger statement
+ Assert.equal(pausedPacket.why.type, "breakpoint");
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ `function functionWithDebuggerStatement() {
+ debugger;
+ return 42;
+ }
+ debugger;
+ functionWithDebuggerStatement();
+ var a = 1;
+ functionWithDebuggerStatement();`,
+ debuggee,
+ "1.8",
+ "nesting-04.js",
+ 1
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_new_source-01.js b/devtools/server/tests/xpcshell/test_new_source-01.js
new file mode 100644
index 0000000000..929865baa8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_new_source-01.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check basic newSource packet sent from server.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ Cu.evalInSandbox(
+ function inc(n) {
+ return n + 1;
+ }.toString(),
+ debuggee
+ );
+
+ const sourcePacket = await waitForEvent(threadFront, "newSource");
+
+ Assert.ok(!!sourcePacket.source);
+ Assert.ok(!!sourcePacket.source.url.match(/test_new_source-01.js$/));
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_new_source-02.js b/devtools/server/tests/xpcshell/test_new_source-02.js
new file mode 100644
index 0000000000..15259b884a
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_new_source-02.js
@@ -0,0 +1,46 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that sourceURL has the correct effect when using threadFront.eval.
+ */
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront, debuggee }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const packet1 = await waitForEvent(threadFront, "newSource");
+
+ Assert.ok(!!packet1.source);
+ Assert.ok(packet1.source.introductionType, "eval");
+
+ commands.scriptCommand.execute(
+ "function f() { }\n//# sourceURL=http://example.com/code.js"
+ );
+
+ const packet2 = await waitForEvent(threadFront, "newSource");
+ dump(JSON.stringify(packet2, null, 2));
+ Assert.ok(!!packet2.source);
+ Assert.ok(!!packet2.source.url.match(/example\.com/));
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable */
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(arg1) {
+ debugger;
+ }
+ stopMe({ obj: true });
+ } +
+ ")()"
+ );
+ /* eslint-enable */
+}
diff --git a/devtools/server/tests/xpcshell/test_nodelistactor.js b/devtools/server/tests/xpcshell/test_nodelistactor.js
new file mode 100644
index 0000000000..eab6bb07e8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_nodelistactor.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test that a NodeListActor initialized with null nodelist doesn't cause
+// exceptions when calling NodeListActor.form.
+
+const {
+ NodeListActor,
+} = require("resource://devtools/server/actors/inspector/node.js");
+
+function run_test() {
+ check_actor_for_list(null);
+ check_actor_for_list([]);
+ check_actor_for_list(["fakenode"]);
+}
+
+function check_actor_for_list(nodelist) {
+ info("Checking NodeListActor with nodelist '" + nodelist + "' works.");
+ const actor = new NodeListActor({}, nodelist);
+ const form = actor.form();
+
+ // No exception occured as a exceptions abort the test.
+ ok(true, "No exceptions occured.");
+ equal(
+ form.length,
+ nodelist ? nodelist.length : 0,
+ "NodeListActor reported correct length."
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-02.js b/devtools/server/tests/xpcshell/test_objectgrips-02.js
new file mode 100644
index 0000000000..810a5009c0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-02.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const args = packet.frame.arguments;
+
+ Assert.equal(args[0].class, "Object");
+
+ const objectFront = threadFront.pauseGrip(args[0]);
+ const response = await objectFront.getPrototype();
+ Assert.ok(response.prototype != undefined);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval(
+ function Constr() {
+ this.a = 1;
+ }.toString()
+ );
+ debuggee.eval(
+ "Constr.prototype = { b: true, c: 'foo' }; var o = new Constr(); stopMe(o)"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-03.js b/devtools/server/tests/xpcshell/test_objectgrips-03.js
new file mode 100644
index 0000000000..c8a51d41d3
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-03.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+ const args = packet.frame.arguments;
+
+ Assert.equal(args[0].class, "Object");
+
+ const objClient = threadFront.pauseGrip(args[0]);
+ let response = await objClient.getProperty("x");
+ Assert.equal(response.descriptor.configurable, true);
+ Assert.equal(response.descriptor.enumerable, true);
+ Assert.equal(response.descriptor.writable, true);
+ Assert.equal(response.descriptor.value, 10);
+
+ response = await objClient.getProperty("y");
+ Assert.equal(response.descriptor.configurable, true);
+ Assert.equal(response.descriptor.enumerable, true);
+ Assert.equal(response.descriptor.writable, true);
+ Assert.equal(response.descriptor.value, "kaiju");
+
+ response = await objClient.getProperty("a");
+ Assert.equal(response.descriptor.configurable, true);
+ Assert.equal(response.descriptor.enumerable, true);
+ Assert.equal(response.descriptor.get.type, "object");
+ Assert.equal(response.descriptor.get.class, "Function");
+ Assert.equal(response.descriptor.set.type, "undefined");
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval("stopMe({ x: 10, y: 'kaiju', get a() { return 42; } })");
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-04.js b/devtools/server/tests/xpcshell/test_objectgrips-04.js
new file mode 100644
index 0000000000..d08705db3c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-04.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const args = packet.frame.arguments;
+
+ Assert.equal(args[0].class, "Object");
+
+ const objectFront = threadFront.pauseGrip(args[0]);
+ const { ownProperties, prototype } =
+ await objectFront.getPrototypeAndProperties();
+ Assert.equal(ownProperties.x.configurable, true);
+ Assert.equal(ownProperties.x.enumerable, true);
+ Assert.equal(ownProperties.x.writable, true);
+ Assert.equal(ownProperties.x.value, 10);
+
+ Assert.equal(ownProperties.y.configurable, true);
+ Assert.equal(ownProperties.y.enumerable, true);
+ Assert.equal(ownProperties.y.writable, true);
+ Assert.equal(ownProperties.y.value, "kaiju");
+
+ Assert.equal(ownProperties.a.configurable, true);
+ Assert.equal(ownProperties.a.enumerable, true);
+ Assert.equal(ownProperties.a.get.getGrip().type, "object");
+ Assert.equal(ownProperties.a.get.getGrip().class, "Function");
+ Assert.equal(ownProperties.a.set.type, "undefined");
+
+ Assert.ok(prototype != undefined);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval("stopMe({ x: 10, y: 'kaiju', get a() { return 42; } })");
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-05.js b/devtools/server/tests/xpcshell/test_objectgrips-05.js
new file mode 100644
index 0000000000..4c6f0f107a
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-05.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that frozen objects report themselves as frozen in their
+ * grip.
+ */
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const obj1 = packet.frame.arguments[0];
+ Assert.ok(obj1.frozen);
+
+ const obj1Client = threadFront.pauseGrip(obj1);
+ Assert.ok(obj1Client.isFrozen);
+
+ const obj2 = packet.frame.arguments[1];
+ Assert.ok(!obj2.frozen);
+
+ const obj2Client = threadFront.pauseGrip(obj2);
+ Assert.ok(!obj2Client.isFrozen);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ /* eslint-disable no-undef */
+ debuggee.eval(
+ "(" +
+ function () {
+ const obj1 = {};
+ Object.freeze(obj1);
+ stopMe(obj1, {});
+ } +
+ "())"
+ );
+ /* eslint-enable no-undef */
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-06.js b/devtools/server/tests/xpcshell/test_objectgrips-06.js
new file mode 100644
index 0000000000..ef3d2b5b66
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-06.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that sealed objects report themselves as sealed in their
+ * grip.
+ */
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const obj1 = packet.frame.arguments[0];
+ Assert.ok(obj1.sealed);
+
+ const obj1Client = threadFront.pauseGrip(obj1);
+ Assert.ok(obj1Client.isSealed);
+
+ const obj2 = packet.frame.arguments[1];
+ Assert.ok(!obj2.sealed);
+
+ const obj2Client = threadFront.pauseGrip(obj2);
+ Assert.ok(!obj2Client.isSealed);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ /* eslint-disable no-undef */
+ debuggee.eval(
+ "(" +
+ function () {
+ const obj1 = {};
+ Object.seal(obj1);
+ stopMe(obj1, {});
+ } +
+ "())"
+ );
+ /* eslint-enable no-undef */
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-07.js b/devtools/server/tests/xpcshell/test_objectgrips-07.js
new file mode 100644
index 0000000000..2a3a0bf00e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-07.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that objects which are not extensible report themselves as
+ * such.
+ */
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const [f, s, ne, e] = packet.frame.arguments;
+ const [fClient, sClient, neClient, eClient] = packet.frame.arguments.map(
+ a => threadFront.pauseGrip(a)
+ );
+
+ Assert.ok(!f.extensible);
+ Assert.ok(!fClient.isExtensible);
+
+ Assert.ok(!s.extensible);
+ Assert.ok(!sClient.isExtensible);
+
+ Assert.ok(!ne.extensible);
+ Assert.ok(!neClient.isExtensible);
+
+ Assert.ok(e.extensible);
+ Assert.ok(eClient.isExtensible);
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ /* eslint-disable no-undef */
+ debuggee.eval(
+ "(" +
+ function () {
+ const f = {};
+ Object.freeze(f);
+ const s = {};
+ Object.seal(s);
+ const ne = {};
+ Object.preventExtensions(ne);
+ stopMe(f, s, ne, {});
+ } +
+ "())"
+ );
+ /* eslint-enable no-undef */
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-08.js b/devtools/server/tests/xpcshell/test_objectgrips-08.js
new file mode 100644
index 0000000000..1a37f19fb8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-08.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const args = packet.frame.arguments;
+
+ Assert.equal(args[0].class, "Object");
+
+ const objClient = threadFront.pauseGrip(args[0]);
+ const response = await objClient.getPrototypeAndProperties();
+ const { a, b, c, d, e, f, g } = response.ownProperties;
+ testPropertyType(a, "Infinity");
+ testPropertyType(b, "-Infinity");
+ testPropertyType(c, "NaN");
+ testPropertyType(d, "-0");
+ testPropertyType(e, "BigInt");
+ testPropertyType(f, "BigInt");
+ testPropertyType(g, "BigInt");
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval(
+ `stopMe({
+ a: Infinity,
+ b: -Infinity,
+ c: NaN,
+ d: -0,
+ e: 1n,
+ f: -2n,
+ g: 0n,
+ })`
+ );
+}
+
+function testPropertyType(prop, expectedType) {
+ Assert.equal(prop.configurable, true);
+ Assert.equal(prop.enumerable, true);
+ Assert.equal(prop.writable, true);
+ Assert.equal(prop.value.type, expectedType);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-14.js b/devtools/server/tests/xpcshell/test_objectgrips-14.js
new file mode 100644
index 0000000000..cff8611e7d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-14.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test out of scope objects with synchronous functions.
+ */
+
+var gDebuggee;
+var gThreadFront;
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ gThreadFront = threadFront;
+ gDebuggee = debuggee;
+ await testObjectGroup();
+ })
+);
+
+function evalCode() {
+ evalCallback(gDebuggee, function runTest() {
+ const ugh = [];
+ let i = 0;
+
+ (function () {
+ (function () {
+ ugh.push(i++);
+ debugger;
+ })();
+ })();
+
+ debugger;
+ });
+}
+
+const testObjectGroup = async function () {
+ let packet = await executeOnNextTickAndWaitForPause(evalCode, gThreadFront);
+
+ const environment = await packet.frame.getEnvironment();
+ const ugh = environment.parent.parent.bindings.variables.ugh;
+ const ughClient = await gThreadFront.pauseGrip(ugh.value);
+
+ packet = await getPrototypeAndProperties(ughClient);
+ packet = await resumeAndWaitForPause(gThreadFront);
+
+ const environment2 = await packet.frame.getEnvironment();
+ const ugh2 = environment2.bindings.variables.ugh;
+ const ugh2Client = gThreadFront.pauseGrip(ugh2.value);
+
+ packet = await getPrototypeAndProperties(ugh2Client);
+ Assert.equal(packet.ownProperties.length.value, 1);
+
+ await resume(gThreadFront);
+};
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-15.js b/devtools/server/tests/xpcshell/test_objectgrips-15.js
new file mode 100644
index 0000000000..3a7aba89c8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-15.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test out of scope objects with async functions.
+ */
+
+var gDebuggee;
+var gThreadFront;
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ gThreadFront = threadFront;
+ gDebuggee = debuggee;
+ await testObjectGroup();
+ })
+);
+
+function evalCode() {
+ evalCallback(gDebuggee, function runTest() {
+ const ugh = [];
+ let i = 0;
+
+ function foo() {
+ ugh.push(i++);
+ debugger;
+ }
+
+ Promise.resolve().then(foo).then(foo);
+ });
+}
+
+const testObjectGroup = async function () {
+ let packet = await executeOnNextTickAndWaitForPause(evalCode, gThreadFront);
+
+ const environment = await packet.frame.getEnvironment();
+ const ugh = environment.parent.bindings.variables.ugh;
+ const ughClient = await gThreadFront.pauseGrip(ugh.value);
+
+ packet = await getPrototypeAndProperties(ughClient);
+
+ packet = await resumeAndWaitForPause(gThreadFront);
+ const environment2 = await packet.frame.getEnvironment();
+ const ugh2 = environment2.parent.bindings.variables.ugh;
+ const ugh2Client = gThreadFront.pauseGrip(ugh2.value);
+
+ packet = await getPrototypeAndProperties(ugh2Client);
+ Assert.equal(packet.ownProperties.length.value, 2);
+
+ await resume(gThreadFront);
+};
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-16.js b/devtools/server/tests/xpcshell/test_objectgrips-16.js
new file mode 100644
index 0000000000..785c3bc36d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-16.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ eval_code,
+ threadFront
+ );
+ const [grip] = packet.frame.arguments;
+
+ // Checks grip.preview properties.
+ check_preview(grip);
+
+ const objClient = threadFront.pauseGrip(grip);
+ const response = await objClient.getPrototypeAndProperties();
+ // Checks the result of getPrototypeAndProperties.
+ check_prototype_and_properties(response);
+
+ await threadFront.resume();
+
+ function eval_code() {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval(`
+ stopMe({
+ [Symbol()]: "first unnamed symbol",
+ [Symbol()]: "second unnamed symbol",
+ [Symbol("named")] : "named symbol",
+ [Symbol.iterator] : function* () {
+ yield 1;
+ yield 2;
+ },
+ x: 10,
+ });
+ `);
+ }
+
+ function check_preview(grip) {
+ Assert.equal(grip.class, "Object");
+
+ const { preview } = grip;
+ Assert.equal(preview.ownProperties.x.configurable, true);
+ Assert.equal(preview.ownProperties.x.enumerable, true);
+ Assert.equal(preview.ownProperties.x.writable, true);
+ Assert.equal(preview.ownProperties.x.value, 10);
+
+ const [
+ firstUnnamedSymbol,
+ secondUnnamedSymbol,
+ namedSymbol,
+ iteratorSymbol,
+ ] = preview.ownSymbols;
+
+ Assert.equal(firstUnnamedSymbol.name, undefined);
+ Assert.equal(firstUnnamedSymbol.type, "symbol");
+ Assert.equal(firstUnnamedSymbol.descriptor.configurable, true);
+ Assert.equal(firstUnnamedSymbol.descriptor.enumerable, true);
+ Assert.equal(firstUnnamedSymbol.descriptor.writable, true);
+ Assert.equal(firstUnnamedSymbol.descriptor.value, "first unnamed symbol");
+
+ Assert.equal(secondUnnamedSymbol.name, undefined);
+ Assert.equal(secondUnnamedSymbol.type, "symbol");
+ Assert.equal(secondUnnamedSymbol.descriptor.configurable, true);
+ Assert.equal(secondUnnamedSymbol.descriptor.enumerable, true);
+ Assert.equal(secondUnnamedSymbol.descriptor.writable, true);
+ Assert.equal(
+ secondUnnamedSymbol.descriptor.value,
+ "second unnamed symbol"
+ );
+
+ Assert.equal(namedSymbol.name, "named");
+ Assert.equal(namedSymbol.type, "symbol");
+ Assert.equal(namedSymbol.descriptor.configurable, true);
+ Assert.equal(namedSymbol.descriptor.enumerable, true);
+ Assert.equal(namedSymbol.descriptor.writable, true);
+ Assert.equal(namedSymbol.descriptor.value, "named symbol");
+
+ Assert.equal(iteratorSymbol.name, "Symbol.iterator");
+ Assert.equal(iteratorSymbol.type, "symbol");
+ Assert.equal(iteratorSymbol.descriptor.configurable, true);
+ Assert.equal(iteratorSymbol.descriptor.enumerable, true);
+ Assert.equal(iteratorSymbol.descriptor.writable, true);
+ Assert.equal(iteratorSymbol.descriptor.value.class, "Function");
+ }
+
+ function check_prototype_and_properties(response) {
+ Assert.equal(response.ownProperties.x.configurable, true);
+ Assert.equal(response.ownProperties.x.enumerable, true);
+ Assert.equal(response.ownProperties.x.writable, true);
+ Assert.equal(response.ownProperties.x.value, 10);
+
+ const [
+ firstUnnamedSymbol,
+ secondUnnamedSymbol,
+ namedSymbol,
+ iteratorSymbol,
+ ] = response.ownSymbols;
+
+ Assert.equal(firstUnnamedSymbol.name, "Symbol()");
+ Assert.equal(firstUnnamedSymbol.descriptor.configurable, true);
+ Assert.equal(firstUnnamedSymbol.descriptor.enumerable, true);
+ Assert.equal(firstUnnamedSymbol.descriptor.writable, true);
+ Assert.equal(firstUnnamedSymbol.descriptor.value, "first unnamed symbol");
+
+ Assert.equal(secondUnnamedSymbol.name, "Symbol()");
+ Assert.equal(secondUnnamedSymbol.descriptor.configurable, true);
+ Assert.equal(secondUnnamedSymbol.descriptor.enumerable, true);
+ Assert.equal(secondUnnamedSymbol.descriptor.writable, true);
+ Assert.equal(
+ secondUnnamedSymbol.descriptor.value,
+ "second unnamed symbol"
+ );
+
+ Assert.equal(namedSymbol.name, "Symbol(named)");
+ Assert.equal(namedSymbol.descriptor.configurable, true);
+ Assert.equal(namedSymbol.descriptor.enumerable, true);
+ Assert.equal(namedSymbol.descriptor.writable, true);
+ Assert.equal(namedSymbol.descriptor.value, "named symbol");
+
+ Assert.equal(iteratorSymbol.name, "Symbol(Symbol.iterator)");
+ Assert.equal(iteratorSymbol.descriptor.configurable, true);
+ Assert.equal(iteratorSymbol.descriptor.enumerable, true);
+ Assert.equal(iteratorSymbol.descriptor.writable, true);
+ Assert.equal(iteratorSymbol.descriptor.value.class, "Function");
+ }
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-17.js b/devtools/server/tests/xpcshell/test_objectgrips-17.js
new file mode 100644
index 0000000000..edaea88eaa
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-17.js
@@ -0,0 +1,320 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+async function testPrincipal(options, globalPrincipal, debuggeeHasXrays) {
+ const { debuggee } = options;
+ // Create a global object with the specified security principal.
+ // If none is specified, use the debuggee.
+ if (globalPrincipal === undefined) {
+ await test(options, {
+ global: debuggee,
+ subsumes: true,
+ isOpaque: false,
+ globalIsInvisible: false,
+ });
+ return;
+ }
+
+ const debuggeePrincipal = Cu.getObjectPrincipal(debuggee);
+ const sameOrigin = debuggeePrincipal.origin === globalPrincipal.origin;
+ const subsumes = debuggeePrincipal.subsumes(globalPrincipal);
+ for (const globalHasXrays of [true, false]) {
+ const isOpaque =
+ subsumes &&
+ globalPrincipal !== systemPrincipal &&
+ ((sameOrigin && debuggeeHasXrays) || globalHasXrays);
+ for (const globalIsInvisible of [true, false]) {
+ let global = Cu.Sandbox(globalPrincipal, {
+ wantXrays: globalHasXrays,
+ invisibleToDebugger: globalIsInvisible,
+ });
+ // Previously, the Sandbox constructor would (bizarrely) waive xrays on
+ // the return Sandbox if wantXrays was false. This has now been fixed,
+ // but we need to mimic that behavior here to make the test continue
+ // to pass.
+ if (!globalHasXrays) {
+ global = Cu.waiveXrays(global);
+ }
+ await test(options, { global, subsumes, isOpaque, globalIsInvisible });
+ }
+ }
+}
+
+async function test({ threadFront, debuggee }, testOptions) {
+ const { global } = testOptions;
+ const packet = await executeOnNextTickAndWaitForPause(eval_code, threadFront);
+ // Get the grips.
+ const [proxyGrip, inheritsProxyGrip, inheritsProxy2Grip] =
+ packet.frame.arguments;
+
+ // Check the grip of the proxy object.
+ check_proxy_grip(debuggee, testOptions, proxyGrip);
+
+ // Check the target and handler slots of the proxy object.
+ const proxyClient = threadFront.pauseGrip(proxyGrip);
+ const proxySlots = await proxyClient.getProxySlots();
+ check_proxy_slots(debuggee, testOptions, proxyGrip, proxySlots);
+
+ // Check the prototype and properties of the proxy object.
+ const proxyResponse = await proxyClient.getPrototypeAndProperties();
+ check_properties(testOptions, proxyResponse.ownProperties, true, false);
+ check_prototype(debuggee, testOptions, proxyResponse.prototype, true, false);
+
+ // Check the prototype and properties of the object which inherits from the proxy.
+ const inheritsProxyClient = threadFront.pauseGrip(inheritsProxyGrip);
+ const inheritsProxyResponse =
+ await inheritsProxyClient.getPrototypeAndProperties();
+ check_properties(
+ testOptions,
+ inheritsProxyResponse.ownProperties,
+ false,
+ false
+ );
+ check_prototype(
+ debuggee,
+ testOptions,
+ inheritsProxyResponse.prototype,
+ false,
+ false
+ );
+
+ // The prototype chain was not iterated if the object was inaccessible, so now check
+ // another object which inherits from the proxy, but was created in the debuggee.
+ const inheritsProxy2Client = threadFront.pauseGrip(inheritsProxy2Grip);
+ const inheritsProxy2Response =
+ await inheritsProxy2Client.getPrototypeAndProperties();
+ check_properties(
+ testOptions,
+ inheritsProxy2Response.ownProperties,
+ false,
+ true
+ );
+ check_prototype(
+ debuggee,
+ testOptions,
+ inheritsProxy2Response.prototype,
+ false,
+ true
+ );
+
+ // Check that none of the above ran proxy traps.
+ strictEqual(global.trapDidRun, false, "No proxy trap did run.");
+
+ // Resume the debugger and finish the current test.
+ await threadFront.resume();
+
+ function eval_code() {
+ // Create objects in `global`, and debug them in `debuggee`. They may get various
+ // kinds of security wrappers, or no wrapper at all.
+ // To detect that no proxy trap runs, the proxy handler should define all possible
+ // traps, but the list is long and may change. Therefore a second proxy is used as
+ // the handler, so that a single `get` trap suffices.
+ global.eval(`
+ var trapDidRun = false;
+ var proxy = new Proxy({}, new Proxy({}, {get: (_, trap) => {
+ trapDidRun = true;
+ throw new Error("proxy trap '" + trap + "' was called.");
+ }}));
+ var inheritsProxy = Object.create(proxy, {x:{value:1}});
+ `);
+ const data = Cu.createObjectIn(debuggee, { defineAs: "data" });
+ data.proxy = global.proxy;
+ data.inheritsProxy = global.inheritsProxy;
+ debuggee.eval(`
+ var inheritsProxy2 = Object.create(data.proxy, {x:{value:1}});
+ stopMe(data.proxy, data.inheritsProxy, inheritsProxy2);
+ `);
+ }
+}
+
+function check_proxy_grip(debuggee, testOptions, grip) {
+ const { global, isOpaque, subsumes, globalIsInvisible } = testOptions;
+ const { preview } = grip;
+
+ if (global === debuggee) {
+ // The proxy has no security wrappers.
+ strictEqual(grip.class, "Proxy", "The grip has a Proxy class.");
+ strictEqual(
+ preview.ownPropertiesLength,
+ 2,
+ "The preview has 2 properties."
+ );
+ const props = preview.ownProperties;
+ ok(props["<target>"].value, "<target> contains the [[ProxyTarget]].");
+ ok(props["<handler>"].value, "<handler> contains the [[ProxyHandler]].");
+ } else if (isOpaque) {
+ // The proxy has opaque security wrappers.
+ strictEqual(grip.class, "Opaque", "The grip has an Opaque class.");
+ strictEqual(grip.ownPropertyLength, 0, "The grip has no properties.");
+ } else if (!subsumes) {
+ // The proxy belongs to compartment not subsumed by the debuggee.
+ strictEqual(grip.class, "Restricted", "The grip has a Restricted class.");
+ strictEqual(
+ grip.ownPropertyLength,
+ undefined,
+ "The grip doesn't know the number of properties."
+ );
+ } else if (globalIsInvisible) {
+ // The proxy belongs to an invisible-to-debugger compartment.
+ strictEqual(
+ grip.class,
+ "InvisibleToDebugger: Object",
+ "The grip has an InvisibleToDebugger class."
+ );
+ ok(
+ !("ownPropertyLength" in grip),
+ "The grip doesn't know the number of properties."
+ );
+ } else {
+ // The proxy has non-opaque security wrappers.
+ strictEqual(grip.class, "Proxy", "The grip has a Proxy class.");
+ strictEqual(
+ preview.ownPropertiesLength,
+ 0,
+ "The preview has no properties."
+ );
+ ok(!("<target>" in preview), "The preview has no <target> property.");
+ ok(!("<handler>" in preview), "The preview has no <handler> property.");
+ }
+}
+
+function check_proxy_slots(debuggee, testOptions, grip, proxySlots) {
+ const { global } = testOptions;
+
+ if (grip.class !== "Proxy") {
+ strictEqual(
+ proxySlots,
+ null,
+ "Slots can only be retrived for Proxy grips."
+ );
+ } else if (global === debuggee) {
+ const { proxyTarget, proxyHandler } = proxySlots;
+ strictEqual(
+ proxyTarget.getGrip().type,
+ "object",
+ "There is a [[ProxyTarget]] grip."
+ );
+ strictEqual(
+ proxyHandler.getGrip().type,
+ "object",
+ "There is a [[ProxyHandler]] grip."
+ );
+ } else {
+ const { proxyTarget, proxyHandler } = proxySlots;
+ strictEqual(
+ proxyTarget.type,
+ "undefined",
+ "There is no [[ProxyTarget]] grip."
+ );
+ strictEqual(
+ proxyHandler.type,
+ "undefined",
+ "There is no [[ProxyHandler]] grip."
+ );
+ }
+}
+
+function check_properties(testOptions, props, isProxy, createdInDebuggee) {
+ const { subsumes, globalIsInvisible } = testOptions;
+ const ownPropertiesLength = Reflect.ownKeys(props).length;
+
+ if (createdInDebuggee || (!isProxy && subsumes && !globalIsInvisible)) {
+ // The debuggee can access the properties.
+ strictEqual(ownPropertiesLength, 1, "1 own property was retrieved.");
+ strictEqual(props.x.value, 1, "The property has the right value.");
+ } else {
+ // The debuggee is not allowed to access the object.
+ strictEqual(ownPropertiesLength, 0, "No own property could be retrieved.");
+ }
+}
+
+function check_prototype(
+ debuggee,
+ testOptions,
+ proto,
+ isProxy,
+ createdInDebuggee
+) {
+ const { global, isOpaque, subsumes, globalIsInvisible } = testOptions;
+ if (isOpaque && !globalIsInvisible && !createdInDebuggee) {
+ // The object is or inherits from a proxy with opaque security wrappers.
+ // The debuggee sees `Object.prototype` when retrieving the prototype.
+ strictEqual(
+ proto.getGrip().class,
+ "Object",
+ "The prototype has a Object class."
+ );
+ } else if (isProxy && isOpaque && globalIsInvisible) {
+ // The object is a proxy with opaque security wrappers in an invisible global.
+ // The debuggee sees an inaccessible `Object.prototype` when retrieving the prototype.
+ strictEqual(
+ proto.getGrip().class,
+ "InvisibleToDebugger: Object",
+ "The prototype has an InvisibleToDebugger class."
+ );
+ } else if (
+ createdInDebuggee ||
+ (!isProxy && subsumes && !globalIsInvisible)
+ ) {
+ // The object inherits from a proxy and has no security wrappers or non-opaque ones.
+ // The debuggee sees the proxy when retrieving the prototype.
+ check_proxy_grip(
+ debuggee,
+ { global, isOpaque, subsumes, globalIsInvisible },
+ proto.getGrip()
+ );
+ } else {
+ // The debuggee is not allowed to access the object. It sees a null prototype.
+ strictEqual(proto.type, "null", "The prototype is null.");
+ }
+}
+
+function createNullPrincipal() {
+ return Services.scriptSecurityManager.createNullPrincipal({});
+}
+
+async function run_tests_in_principal(
+ options,
+ debuggeePrincipal,
+ debuggeeHasXrays
+) {
+ const { debuggee } = options;
+ debuggee.eval(
+ function stopMe(arg1, arg2) {
+ debugger;
+ }.toString()
+ );
+
+ // Test objects created in the debuggee.
+ await testPrincipal(options, undefined, debuggeeHasXrays);
+
+ // Test objects created in a system principal new global.
+ await testPrincipal(options, systemPrincipal, debuggeeHasXrays);
+
+ // Test objects created in a cross-origin null principal new global.
+ await testPrincipal(options, createNullPrincipal(), debuggeeHasXrays);
+
+ if (debuggeePrincipal != systemPrincipal) {
+ // Test objects created in a same-origin principal new global.
+ await testPrincipal(options, debuggeePrincipal, debuggeeHasXrays);
+ }
+}
+
+for (const principal of [systemPrincipal, createNullPrincipal()]) {
+ for (const wantXrays of [true, false]) {
+ add_task(
+ threadFrontTest(
+ options => run_tests_in_principal(options, principal, wantXrays),
+ { principal, wantXrays }
+ )
+ );
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-18.js b/devtools/server/tests/xpcshell/test_objectgrips-18.js
new file mode 100644
index 0000000000..90c38d99a9
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-18.js
@@ -0,0 +1,173 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ eval_code,
+ threadFront
+ );
+
+ const [grip] = packet.frame.arguments;
+
+ const objectFront = threadFront.pauseGrip(grip);
+
+ // Checks the result of enumProperties.
+ let response = await objectFront.enumProperties({});
+ await check_enum_properties(response);
+
+ // Checks the result of enumSymbols.
+ response = await objectFront.enumSymbols();
+ await check_enum_symbols(response);
+
+ await threadFront.resume();
+
+ function eval_code() {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ var obj = Array.from({length: 10})
+ .reduce((res, _, i) => {
+ res["property_" + i + "_key"] = "property_" + i + "_value";
+ res[Symbol("symbol_" + i)] = "symbol_" + i + "_value";
+ return res;
+ }, {});
+
+ obj[Symbol()] = "first unnamed symbol";
+ obj[Symbol()] = "second unnamed symbol";
+ obj[Symbol.iterator] = function* () {
+ yield 1;
+ yield 2;
+ };
+
+ stopMe(obj);
+ `);
+ }
+
+ async function check_enum_properties(iterator) {
+ equal(iterator.count, 10, "iterator.count has the expected value");
+
+ info("Check iterator.slice response for all properties");
+ let sliceResponse = await iterator.slice(0, iterator.count);
+ ok(
+ sliceResponse &&
+ Object.getOwnPropertyNames(sliceResponse).includes("ownProperties"),
+ "The response object has an ownProperties property"
+ );
+
+ let { ownProperties } = sliceResponse;
+ let names = Object.keys(ownProperties);
+ equal(
+ names.length,
+ iterator.count,
+ "The response has the expected number of properties"
+ );
+ for (let i = 0; i < names.length; i++) {
+ const name = names[i];
+ equal(name, `property_${i}_key`);
+ equal(ownProperties[name].value, `property_${i}_value`);
+ }
+
+ info("Check iterator.all response");
+ const allResponse = await iterator.all();
+ deepEqual(
+ allResponse,
+ sliceResponse,
+ "iterator.all response has the expected data"
+ );
+
+ info("Check iterator response for 2 properties only");
+ sliceResponse = await iterator.slice(2, 2);
+ ok(
+ sliceResponse &&
+ Object.getOwnPropertyNames(sliceResponse).includes("ownProperties"),
+ "The response object has an ownProperties property"
+ );
+
+ ownProperties = sliceResponse.ownProperties;
+ names = Object.keys(ownProperties);
+ equal(
+ names.length,
+ 2,
+ "The response has the expected number of properties"
+ );
+ equal(names[0], `property_2_key`);
+ equal(names[1], `property_3_key`);
+ equal(ownProperties[names[0]].value, `property_2_value`);
+ equal(ownProperties[names[1]].value, `property_3_value`);
+ }
+
+ async function check_enum_symbols(iterator) {
+ equal(iterator.count, 13, "iterator.count has the expected value");
+
+ info("Check iterator.slice response for all symbols");
+ let sliceResponse = await iterator.slice(0, iterator.count);
+ ok(
+ sliceResponse &&
+ Object.getOwnPropertyNames(sliceResponse).includes("ownSymbols"),
+ "The response object has an ownSymbols property"
+ );
+
+ let { ownSymbols } = sliceResponse;
+ equal(
+ ownSymbols.length,
+ iterator.count,
+ "The response has the expected number of symbols"
+ );
+ for (let i = 0; i < 10; i++) {
+ const symbol = ownSymbols[i];
+ equal(symbol.name, `Symbol(symbol_${i})`);
+ equal(symbol.descriptor.value, `symbol_${i}_value`);
+ }
+ const firstUnnamedSymbol = ownSymbols[10];
+ equal(firstUnnamedSymbol.name, "Symbol()");
+ equal(firstUnnamedSymbol.descriptor.value, "first unnamed symbol");
+
+ const secondUnnamedSymbol = ownSymbols[11];
+ equal(secondUnnamedSymbol.name, "Symbol()");
+ equal(secondUnnamedSymbol.descriptor.value, "second unnamed symbol");
+
+ const iteratorSymbol = ownSymbols[12];
+ equal(iteratorSymbol.name, "Symbol(Symbol.iterator)");
+ equal(iteratorSymbol.descriptor.value.getGrip().class, "Function");
+
+ info("Check iterator.all response");
+ const allResponse = await iterator.all();
+ deepEqual(
+ allResponse,
+ sliceResponse,
+ "iterator.all response has the expected data"
+ );
+
+ info("Check iterator response for 2 symbols only");
+ sliceResponse = await iterator.slice(9, 2);
+ ok(
+ sliceResponse &&
+ Object.getOwnPropertyNames(sliceResponse).includes("ownSymbols"),
+ "The response object has an ownSymbols property"
+ );
+
+ ownSymbols = sliceResponse.ownSymbols;
+ equal(
+ ownSymbols.length,
+ 2,
+ "The response has the expected number of symbols"
+ );
+ equal(ownSymbols[0].name, "Symbol(symbol_9)");
+ equal(ownSymbols[0].descriptor.value, "symbol_9_value");
+ equal(ownSymbols[1].name, "Symbol()");
+ equal(ownSymbols[1].descriptor.value, "first unnamed symbol");
+ }
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-19.js b/devtools/server/tests/xpcshell/test_objectgrips-19.js
new file mode 100644
index 0000000000..655c7d0f43
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-19.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ const tests = [
+ {
+ value: true,
+ class: "Boolean",
+ },
+ {
+ value: 123,
+ class: "Number",
+ },
+ {
+ value: "foo",
+ class: "String",
+ },
+ {
+ value: Symbol("bar"),
+ class: "Symbol",
+ name: "bar",
+ },
+ ];
+ for (const data of tests) {
+ debuggee.primitive = data.value;
+ const packet = await executeOnNextTickAndWaitForPause(() => {
+ debuggee.eval("stopMe(Object(primitive));");
+ }, threadFront);
+
+ const [grip] = packet.frame.arguments;
+ check_wrapped_primitive_grip(grip, data);
+
+ await threadFront.resume();
+ }
+ })
+);
+
+function check_wrapped_primitive_grip(grip, data) {
+ strictEqual(grip.class, data.class, "The grip has the proper class.");
+
+ if (!grip.preview) {
+ // In a worker thread Cu does not exist, the objects are considered unsafe and
+ // can't be unwrapped, so there is no preview.
+ return;
+ }
+
+ const value = grip.preview.wrappedValue;
+ if (data.class === "Symbol") {
+ strictEqual(
+ value.type,
+ "symbol",
+ "The wrapped value grip has symbol type."
+ );
+ strictEqual(
+ value.name,
+ data.name,
+ "The wrapped value grip has the proper name."
+ );
+ } else {
+ strictEqual(value, data.value, "The wrapped value is the primitive one.");
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-20.js b/devtools/server/tests/xpcshell/test_objectgrips-20.js
new file mode 100644
index 0000000000..5027ca31a7
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-20.js
@@ -0,0 +1,387 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that onEnumProperties returns the expected data
+// when passing `ignoreNonIndexedProperties` and `ignoreIndexedProperties` options
+// with various objects. (See Bug 1403065)
+
+const DO_NOT_CHECK_VALUE = Symbol();
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ const testCases = [
+ {
+ evaledObject: { a: 10 },
+ expectedIndexedProperties: [],
+ expectedNonIndexedProperties: [["a", 10]],
+ },
+ {
+ evaledObject: { length: 10 },
+ expectedIndexedProperties: [],
+ expectedNonIndexedProperties: [["length", 10]],
+ },
+ {
+ evaledObject: { a: 10, 0: "indexed" },
+ expectedIndexedProperties: [["0", "indexed"]],
+ expectedNonIndexedProperties: [["a", 10]],
+ },
+ {
+ evaledObject: { 1: 1, length: 42, a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", 42],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: 2.34, a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", 2.34],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: -0, a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", -0],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: -10, a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", -10],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: true, a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", true],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: null, a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", DO_NOT_CHECK_VALUE],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: Math.pow(2, 53), a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", 9007199254740992],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: "fake", a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", "fake"],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 1: 1, length: Infinity, a: 10 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [
+ ["length", DO_NOT_CHECK_VALUE],
+ ["a", 10],
+ ],
+ },
+ {
+ evaledObject: { 0: 0, length: 0 },
+ expectedIndexedProperties: [["0", 0]],
+ expectedNonIndexedProperties: [["length", 0]],
+ },
+ {
+ evaledObject: { 0: 0, 1: 1, length: 1 },
+ expectedIndexedProperties: [
+ ["0", 0],
+ ["1", 1],
+ ],
+ expectedNonIndexedProperties: [["length", 1]],
+ },
+ {
+ evaledObject: { length: 0 },
+ expectedIndexedProperties: [],
+ expectedNonIndexedProperties: [["length", 0]],
+ },
+ {
+ evaledObject: { 1: 1 },
+ expectedIndexedProperties: [["1", 1]],
+ expectedNonIndexedProperties: [],
+ },
+ {
+ evaledObject: { a: 1, [2 ** 32 - 2]: 2, [2 ** 32 - 1]: 3 },
+ expectedIndexedProperties: [["4294967294", 2]],
+ expectedNonIndexedProperties: [
+ ["a", 1],
+ ["4294967295", 3],
+ ],
+ },
+ {
+ evaledObject: `(() => {
+ x = [12, 42];
+ x.foo = 90;
+ return x;
+ })()`,
+ expectedIndexedProperties: [
+ ["0", 12],
+ ["1", 42],
+ ],
+ expectedNonIndexedProperties: [
+ ["length", 2],
+ ["foo", 90],
+ ],
+ },
+ {
+ evaledObject: `(() => {
+ x = [12, 42];
+ x.length = 3;
+ return x;
+ })()`,
+ expectedIndexedProperties: [
+ ["0", 12],
+ ["1", 42],
+ ["2", undefined],
+ ],
+ expectedNonIndexedProperties: [["length", 3]],
+ },
+ {
+ evaledObject: `(() => {
+ x = [12, 42];
+ x.length = 1;
+ return x;
+ })()`,
+ expectedIndexedProperties: [["0", 12]],
+ expectedNonIndexedProperties: [["length", 1]],
+ },
+ {
+ evaledObject: `(() => {
+ x = [, 42,,];
+ x.foo = 90;
+ return x;
+ })()`,
+ expectedIndexedProperties: [
+ ["0", undefined],
+ ["1", 42],
+ ["2", undefined],
+ ],
+ expectedNonIndexedProperties: [
+ ["length", 3],
+ ["foo", 90],
+ ],
+ },
+ {
+ evaledObject: `(() => {
+ x = Array(2);
+ x.foo = "bar";
+ x.bar = "foo";
+ return x;
+ })()`,
+ expectedIndexedProperties: [
+ ["0", undefined],
+ ["1", undefined],
+ ],
+ expectedNonIndexedProperties: [
+ ["length", 2],
+ ["foo", "bar"],
+ ["bar", "foo"],
+ ],
+ },
+ {
+ evaledObject: `(() => {
+ x = new Int8Array(new ArrayBuffer(2));
+ x.foo = "bar";
+ x.bar = "foo";
+ return x;
+ })()`,
+ expectedIndexedProperties: [
+ ["0", 0],
+ ["1", 0],
+ ],
+ expectedNonIndexedProperties: [
+ ["foo", "bar"],
+ ["bar", "foo"],
+ ["length", 2],
+ ["buffer", DO_NOT_CHECK_VALUE],
+ ["byteLength", 2],
+ ["byteOffset", 0],
+ ],
+ },
+ {
+ evaledObject: `(() => {
+ x = new Int8Array([1, 2]);
+ Object.defineProperty(x, 'length', {value: 0});
+ return x;
+ })()`,
+ expectedIndexedProperties: [
+ ["0", 1],
+ ["1", 2],
+ ],
+ expectedNonIndexedProperties: [
+ ["length", 0],
+ ["buffer", DO_NOT_CHECK_VALUE],
+ ["byteLength", 2],
+ ["byteOffset", 0],
+ ],
+ },
+ {
+ evaledObject: `(() => {
+ x = new Int32Array([1, 2]);
+ Object.setPrototypeOf(x, null);
+ return x;
+ })()`,
+ expectedIndexedProperties: [
+ ["0", 1],
+ ["1", 2],
+ ],
+ expectedNonIndexedProperties: [],
+ },
+ {
+ evaledObject: `(() => {
+ return new (class extends Int8Array {})([1, 2]);
+ })()`,
+ expectedIndexedProperties: [
+ ["0", 1],
+ ["1", 2],
+ ],
+ expectedNonIndexedProperties: [
+ ["length", 2],
+ ["buffer", DO_NOT_CHECK_VALUE],
+ ["byteLength", 2],
+ ["byteOffset", 0],
+ ],
+ },
+ ];
+
+ for (const test of testCases) {
+ await test_object_grip(debuggee, client, threadFront, test);
+ }
+ })
+);
+
+async function test_object_grip(
+ debuggee,
+ dbgClient,
+ threadFront,
+ testData = {}
+) {
+ const {
+ evaledObject,
+ expectedIndexedProperties,
+ expectedNonIndexedProperties,
+ } = testData;
+
+ const packet = await executeOnNextTickAndWaitForPause(eval_code, threadFront);
+
+ const [grip] = packet.frame.arguments;
+
+ const objClient = threadFront.pauseGrip(grip);
+
+ info(`
+ Check enumProperties response for
+ ${
+ typeof evaledObject === "string"
+ ? evaledObject
+ : JSON.stringify(evaledObject)
+ }
+ `);
+
+ // Checks the result of enumProperties.
+ let response = await objClient.enumProperties({
+ ignoreNonIndexedProperties: true,
+ });
+ await check_enum_properties(response, expectedIndexedProperties);
+
+ response = await objClient.enumProperties({
+ ignoreIndexedProperties: true,
+ });
+ await check_enum_properties(response, expectedNonIndexedProperties);
+
+ await threadFront.resume();
+
+ function eval_code() {
+ // Be sure to run debuggee code in its own HTML 'task', so that when we call
+ // the onDebuggerStatement hook, the test's own microtasks don't get suspended
+ // along with the debuggee's.
+ do_timeout(0, () => {
+ debuggee.eval(`
+ stopMe(${
+ typeof evaledObject === "string"
+ ? evaledObject
+ : JSON.stringify(evaledObject)
+ });
+ `);
+ });
+ }
+}
+
+async function check_enum_properties(iterator, expected = []) {
+ equal(
+ iterator.count,
+ expected.length,
+ "iterator.count has the expected value"
+ );
+
+ info("Check iterator.slice response for all properties");
+ const sliceResponse = await iterator.slice(0, iterator.count);
+ ok(
+ sliceResponse &&
+ Object.getOwnPropertyNames(sliceResponse).includes("ownProperties"),
+ "The response object has an ownProperties property"
+ );
+
+ const { ownProperties } = sliceResponse;
+ const names = Object.getOwnPropertyNames(ownProperties);
+ equal(
+ names.length,
+ expected.length,
+ "The response has the expected number of properties"
+ );
+ for (let i = 0; i < names.length; i++) {
+ const name = names[i];
+ const [key, value] = expected[i];
+ equal(name, key, "Property has the expected name");
+ const property = ownProperties[name];
+
+ if (value === DO_NOT_CHECK_VALUE) {
+ return;
+ }
+
+ if (value === undefined) {
+ equal(
+ property,
+ undefined,
+ `Response has no value for the "${key}" property`
+ );
+ } else {
+ const propValue = property.hasOwnProperty("value")
+ ? property.value
+ : property.getterValue;
+ equal(propValue, value, `Property "${key}" has the expected value`);
+ }
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-21.js b/devtools/server/tests/xpcshell/test_objectgrips-21.js
new file mode 100644
index 0000000000..88296f7786
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-21.js
@@ -0,0 +1,396 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+// Run test_unsafe_grips twice, one against a system principal debuggee
+// and another time with a null principal debuggee
+
+// The following tests work like this:
+// - The specified code is evaluated in a system principal.
+// `Cu`, `systemPrincipal` and `Services` are provided as global variables.
+// - The resulting object is debugged in a system or null principal debuggee,
+// depending on in which list the test is placed.
+// It is tested according to the specified test parameters.
+// - An ordinary object that inherits from the resulting one is also debugged.
+// This is just to check that it can be normally debugged even with an unsafe
+// object in the prototype. The specified test parameters do not apply.
+
+// The following tests are defined via properties with the following defaults.
+const defaults = {
+ // The class of the grip.
+ class: "Restricted",
+
+ // The stringification of the object
+ string: "",
+
+ // Whether the object (not its grip) has class "Function".
+ isFunction: false,
+
+ // Whether the grip has a preview property.
+ hasPreview: true,
+
+ // Code that assigns the object to be tested into the obj variable.
+ code: "var obj = {}",
+
+ // The type of the grip of the prototype.
+ protoType: "null",
+
+ // Whether the object has some own string properties.
+ hasOwnPropertyNames: false,
+
+ // Whether the object has some own symbol properties.
+ hasOwnPropertySymbols: false,
+
+ // The descriptor obtained when retrieving property "x" or Symbol("x").
+ property: undefined,
+
+ // Code evaluated after the test, whose result is expected to be true.
+ afterTest: "true == true",
+};
+
+// The following tests use a system principal debuggee.
+const systemPrincipalTests = [
+ {
+ // Dead objects throw a TypeError when accessing properties.
+ class: "DeadObject",
+ string: "<dead object>",
+ code: `
+ var obj = Cu.Sandbox(null);
+ Cu.nukeSandbox(obj);
+ `,
+ property: descriptor({ value: "TypeError" }),
+ },
+ {
+ // This proxy checks that no trap runs (using a second proxy as the handler
+ // there is no need to maintain a list of all possible traps).
+ class: "Proxy",
+ string: "<proxy>",
+ code: `
+ var trapDidRun = false;
+ var obj = new Proxy({}, new Proxy({}, {get: (_, trap) => {
+ trapDidRun = true;
+ throw new Error("proxy trap '" + trap + "' was called.");
+ }}));
+ `,
+ afterTest: "trapDidRun === false",
+ },
+ {
+ // Like the previous test, but now the proxy has a Function class.
+ class: "Proxy",
+ string: "<proxy>",
+ isFunction: true,
+ code: `
+ var trapDidRun = false;
+ var obj = new Proxy(function(){}, new Proxy({}, {get: (_, trap) => {
+ trapDidRun = true;
+ throw new Error("proxy trap '" + trap + "' was called.(function)");
+ }}));
+ `,
+ afterTest: "trapDidRun === false",
+ },
+ {
+ // Invisisible-to-debugger objects can't be unwrapped, so we don't know if
+ // they are proxies. Thus they shouldn't be accessed.
+ class: "InvisibleToDebugger: Array",
+ string: "<invisibleToDebugger>",
+ hasPreview: false,
+ code: `
+ var s = Cu.Sandbox(systemPrincipal, {invisibleToDebugger: true});
+ var obj = s.eval("[1, 2, 3]");
+ `,
+ },
+ {
+ // Like the previous test, but now the object has a Function class.
+ class: "InvisibleToDebugger: Function",
+ string: "<invisibleToDebugger>",
+ isFunction: true,
+ hasPreview: false,
+ code: `
+ var s = Cu.Sandbox(systemPrincipal, {invisibleToDebugger: true});
+ var obj = s.eval("(function func(arg){})");
+ `,
+ },
+ {
+ // Cu.Sandbox is a WrappedNative that throws when accessing properties.
+ class: "nsXPCComponents_utils_Sandbox",
+ string: "[object nsXPCComponents_utils_Sandbox]",
+ code: `var obj = Cu.Sandbox;`,
+ protoType: "object",
+ },
+];
+
+// The following tests run code in a system principal, but the resulting object
+// is debugged in a null principal.
+const nullPrincipalTests = [
+ {
+ // The null principal gets undefined when attempting to access properties.
+ string: "[object Object]",
+ code: `var obj = {x: -1};`,
+ },
+ {
+ // For arrays it's an error instead of undefined.
+ string: "[object Object]",
+ code: `var obj = [1, 2, 3];`,
+ property: descriptor({ value: "Error" }),
+ },
+ {
+ // For functions it's also an error.
+ string: "function func(arg){}",
+ isFunction: true,
+ hasPreview: false,
+ code: `var obj = function func(arg){};`,
+ property: descriptor({ value: "Error" }),
+ },
+ {
+ // Check that no proxy trap runs.
+ string: "[object Object]",
+ code: `
+ var trapDidRun = false;
+ var obj = new Proxy([], new Proxy({}, {get: (_, trap) => {
+ trapDidRun = true;
+ throw new Error("proxy trap '" + trap + "' was called.");
+ }}));
+ `,
+ property: descriptor({ value: "Error" }),
+ afterTest: `trapDidRun === false`,
+ },
+ {
+ // Like the previous test, but now the object is a callable Proxy.
+ string: "function () {\n [native code]\n}",
+ isFunction: true,
+ hasPreview: false,
+ code: `
+ var trapDidRun = false;
+ var obj = new Proxy(function(){}, new Proxy({}, {get: (_, trap) => {
+ trapDidRun = true;
+ throw new Error("proxy trap '" + trap + "' was called.");
+ }}));
+ `,
+ property: descriptor({ value: "Error" }),
+ afterTest: `trapDidRun === false`,
+ },
+ {
+ // Cross-origin Window objects do expose some properties and have a preview.
+ string: "[object Object]",
+ code: `var obj = Services.appShell.createWindowlessBrowser().document.defaultView;`,
+ hasOwnPropertyNames: true,
+ hasOwnPropertySymbols: true,
+ property: descriptor({ value: "SecurityError" }),
+ previewUrl: "about:blank",
+ },
+ {
+ // Cross-origin Location objects do expose some properties and have a preview.
+ string: "[object Object]",
+ code: `var obj = Services.appShell.createWindowlessBrowser().document.defaultView
+ .location;`,
+ hasOwnPropertyNames: true,
+ hasOwnPropertySymbols: true,
+ property: descriptor({ value: "SecurityError" }),
+ },
+];
+
+function descriptor(descr) {
+ return Object.assign(
+ {
+ configurable: false,
+ writable: false,
+ enumerable: false,
+ value: undefined,
+ },
+ descr
+ );
+}
+
+async function test_unsafe_grips(
+ { threadFront, debuggee, isWorkerServer },
+ tests
+) {
+ debuggee.eval(
+ function stopMe(arg1, arg2) {
+ debugger;
+ }.toString()
+ );
+
+ for (let data of tests) {
+ data = { ...defaults, ...data };
+
+ // Run the code and test the results.
+ const sandbox = Cu.Sandbox(systemPrincipal);
+ Object.assign(sandbox, { Services, systemPrincipal, Cu });
+ sandbox.eval(data.code);
+ debuggee.obj = sandbox.obj;
+ const inherits = `Object.create(obj, {
+ x: {value: 1},
+ [Symbol.for("x")]: {value: 2}
+ })`;
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => debuggee.eval(`stopMe(obj, ${inherits});`),
+ threadFront
+ );
+
+ const [objGrip, inheritsGrip] = packet.frame.arguments;
+ for (const grip of [objGrip, inheritsGrip]) {
+ const isUnsafe = grip === objGrip;
+ // If `isUnsafe` is true, the parameters in `data` will be used to assert
+ // against `objGrip`, the grip of the object `obj` created by the test.
+ // Otherwise, the grip will refer to `inherits`, an ordinary object which
+ // inherits from `obj`. Then all checks are hardcoded because in every test
+ // all methods are expected to work the same on `inheritsGrip`.
+ check_grip(grip, data, isUnsafe, isWorkerServer);
+
+ const objClient = threadFront.pauseGrip(grip);
+ let response, slice;
+
+ response = await objClient.getPrototypeAndProperties();
+ check_properties(response.ownProperties, data, isUnsafe);
+ check_symbols(response.ownSymbols, data, isUnsafe);
+ check_prototype(response.prototype, data, isUnsafe, isWorkerServer);
+
+ response = await objClient.enumProperties({
+ ignoreIndexedProperties: true,
+ });
+ slice = await response.slice(0, response.count);
+ check_properties(slice.ownProperties, data, isUnsafe);
+
+ response = await objClient.enumProperties({});
+ slice = await response.slice(0, response.count);
+ check_properties(slice.ownProperties, data, isUnsafe);
+
+ response = await objClient.getProperty("x");
+ check_property(response.descriptor, data, isUnsafe);
+
+ response = await objClient.enumSymbols();
+ slice = await response.slice(0, response.count);
+ check_symbol_names(slice.ownSymbols, data, isUnsafe);
+
+ response = await objClient.getProperty(Symbol.for("x"));
+ check_symbol(response.descriptor, data, isUnsafe);
+
+ response = await objClient.getPrototype();
+ check_prototype(response.prototype, data, isUnsafe, isWorkerServer);
+ }
+
+ await threadFront.resume();
+
+ ok(sandbox.eval(data.afterTest), "Check after test passes");
+ }
+}
+
+function check_grip(grip, data, isUnsafe, isWorkerServer) {
+ if (isUnsafe) {
+ strictEqual(grip.class, data.class, "The grip has the proper class.");
+ strictEqual("preview" in grip, data.hasPreview, "Check preview presence.");
+ // preview.url isn't populated on worker server.
+ if (data.previewUrl && !isWorkerServer) {
+ console.trace();
+ strictEqual(
+ grip.preview.url,
+ data.previewUrl,
+ `Check preview.url for "${data.code}".`
+ );
+ }
+ } else {
+ strictEqual(grip.class, "Object", "The grip has 'Object' class.");
+ ok("preview" in grip, "The grip has a preview.");
+ }
+}
+
+function check_properties(props, data, isUnsafe) {
+ const propNames = Reflect.ownKeys(props);
+ check_property_names(propNames, data, isUnsafe);
+ if (isUnsafe) {
+ deepEqual(props.x, undefined, "The property does not exist.");
+ } else {
+ strictEqual(props.x.value, 1, "The property has the right value.");
+ }
+}
+
+function check_property_names(props, data, isUnsafe) {
+ if (isUnsafe) {
+ strictEqual(
+ !!props.length,
+ data.hasOwnPropertyNames,
+ "Check presence of own string properties."
+ );
+ } else {
+ strictEqual(props.length, 1, "1 own property was retrieved.");
+ strictEqual(props[0], "x", "The property has the right name.");
+ }
+}
+
+function check_property(descr, data, isUnsafe) {
+ if (isUnsafe) {
+ deepEqual(descr, data.property, "Got the right property descriptor.");
+ } else {
+ strictEqual(descr.value, 1, "The property has the right value.");
+ }
+}
+
+function check_symbols(symbols, data, isUnsafe) {
+ check_symbol_names(symbols, data, isUnsafe);
+ if (!isUnsafe) {
+ check_symbol(symbols[0].descriptor, data, isUnsafe);
+ }
+}
+
+function check_symbol_names(props, data, isUnsafe) {
+ if (isUnsafe) {
+ strictEqual(
+ !!props.length,
+ data.hasOwnPropertySymbols,
+ "Check presence of own symbol properties."
+ );
+ } else {
+ strictEqual(props.length, 1, "1 own symbol property was retrieved.");
+ strictEqual(props[0].name, "Symbol(x)", "The symbol has the right name.");
+ }
+}
+
+function check_symbol(descr, data, isUnsafe) {
+ if (isUnsafe) {
+ deepEqual(
+ descr,
+ data.property,
+ "Got the right symbol property descriptor."
+ );
+ } else {
+ strictEqual(descr.value, 2, "The symbol property has the right value.");
+ }
+}
+
+function check_prototype(proto, data, isUnsafe, isWorkerServer) {
+ const protoGrip = proto && proto.getGrip ? proto.getGrip() : proto;
+ if (isUnsafe) {
+ deepEqual(protoGrip.type, data.protoType, "Got the right prototype type.");
+ } else {
+ check_grip(protoGrip, data, true, isWorkerServer);
+ }
+}
+
+// threadFrontTest uses systemPrincipal by default, but let's be explicit here.
+add_task(
+ threadFrontTest(
+ options => {
+ return test_unsafe_grips(options, systemPrincipalTests, "system");
+ },
+ { principal: systemPrincipal }
+ )
+);
+
+const nullPrincipal = Services.scriptSecurityManager.createNullPrincipal({});
+add_task(
+ threadFrontTest(
+ options => {
+ return test_unsafe_grips(options, nullPrincipalTests, "null");
+ },
+ { principal: nullPrincipal }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-22.js b/devtools/server/tests/xpcshell/test_objectgrips-22.js
new file mode 100644
index 0000000000..34264f5534
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-22.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const [grip] = packet.frame.arguments;
+ const objClient = threadFront.pauseGrip(grip);
+ const iterator = await objClient.enumSymbols();
+ const { ownSymbols } = await iterator.slice(0, iterator.count);
+
+ strictEqual(ownSymbols.length, 1, "There is 1 symbol property.");
+ const { name, descriptor } = ownSymbols[0];
+ strictEqual(name, "Symbol(sym)", "Got right symbol name.");
+ deepEqual(
+ descriptor,
+ {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: 1,
+ },
+ "Got right property descriptor."
+ );
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval(
+ `stopMe(Object.defineProperty({}, Symbol("sym"), {value: 1}));`
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-23.js b/devtools/server/tests/xpcshell/test_objectgrips-23.js
new file mode 100644
index 0000000000..b44beb2c2d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-23.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that ES6 classes grip have the expected properties.
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const [grip] = packet.frame.arguments;
+ strictEqual(
+ grip.class,
+ "Function",
+ `Grip has expected value for "class" property`
+ );
+ strictEqual(
+ grip.isClassConstructor,
+ true,
+ `Grip has expected value for "isClassConstructor" property`
+ );
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(`
+ class MyClass {};
+ stopMe(MyClass);
+
+ function stopMe(arg1) {
+ debugger;
+ }
+ `);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-24.js b/devtools/server/tests/xpcshell/test_objectgrips-24.js
new file mode 100644
index 0000000000..9d541c108d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-24.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that ES6 classes grip have the expected properties.
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ debuggee.eval(
+ function stopMe() {
+ debugger;
+ }.toString()
+ );
+
+ const tests = [
+ {
+ fn: `function(){}`,
+ isAsync: false,
+ isGenerator: false,
+ },
+ {
+ fn: `async function(){}`,
+ isAsync: true,
+ isGenerator: false,
+ },
+ {
+ fn: `function *(){}`,
+ isAsync: false,
+ isGenerator: true,
+ },
+ {
+ fn: `async function *(){}`,
+ isAsync: true,
+ isGenerator: true,
+ },
+ ];
+
+ for (const { fn, isAsync, isGenerator } of tests) {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => debuggee.eval(`stopMe(${fn})`),
+ threadFront
+ );
+ const [grip] = packet.frame.arguments;
+ strictEqual(grip.class, "Function");
+ strictEqual(grip.isAsync, isAsync);
+ strictEqual(grip.isGenerator, isGenerator);
+
+ await threadFront.resume();
+ }
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-25.js b/devtools/server/tests/xpcshell/test_objectgrips-25.js
new file mode 100644
index 0000000000..f80572bb19
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-25.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test object with private properties (preview + enumPrivateProperties)
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(obj) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval(`
+ class MyClass {
+ constructor(name, password) {
+ this.name = name;
+ this.#password = password;
+ }
+
+ #password;
+ #salt = "sEcr3t";
+ #getSaltedPassword() {
+ return this.#password + this.#salt;
+ }
+ }
+
+ stopMe(new MyClass("Susie", "p4$$w0rD"));
+ `);
+}
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const [grip] = packet.frame.arguments;
+
+ let { privateProperties } = grip.preview;
+ strictEqual(
+ privateProperties.length,
+ 2,
+ "There is 2 private properties in the grip preview"
+ );
+ let [password, salt] = privateProperties;
+
+ strictEqual(
+ password.name,
+ "#password",
+ "Got expected name for #password private property in preview"
+ );
+ deepEqual(
+ password.descriptor,
+ {
+ configurable: true,
+ enumerable: false,
+ writable: true,
+ value: "p4$$w0rD",
+ },
+ "Got expected property descriptor for #password in preview"
+ );
+
+ strictEqual(
+ salt.name,
+ "#salt",
+ "Got expected name for #salt private property in preview"
+ );
+ deepEqual(
+ salt.descriptor,
+ {
+ configurable: true,
+ enumerable: false,
+ writable: true,
+ value: "sEcr3t",
+ },
+ "Got expected property descriptor for #salt in preview"
+ );
+
+ const objClient = threadFront.pauseGrip(grip);
+ const iterator = await objClient.enumPrivateProperties();
+ ({ privateProperties } = await iterator.slice(0, iterator.count));
+
+ strictEqual(
+ privateProperties.length,
+ 2,
+ "enumPrivateProperties returned 2 private properties."
+ );
+ [password, salt] = privateProperties;
+
+ strictEqual(
+ password.name,
+ "#password",
+ "Got expected name for #password private property via enumPrivateProperties"
+ );
+ deepEqual(
+ password.descriptor,
+ {
+ configurable: true,
+ enumerable: false,
+ writable: true,
+ value: "p4$$w0rD",
+ },
+ "Got expected property descriptor for #password via enumPrivateProperties"
+ );
+
+ strictEqual(
+ salt.name,
+ "#salt",
+ "Got expected name for #salt private property via enumPrivateProperties"
+ );
+ deepEqual(
+ salt.descriptor,
+ {
+ configurable: true,
+ enumerable: false,
+ writable: true,
+ value: "sEcr3t",
+ },
+ "Got expected property descriptor for #salt via enumPrivateProperties"
+ );
+
+ await threadFront.resume();
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-01.js b/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-01.js
new file mode 100644
index 0000000000..f576f16a5e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-01.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const arg1 = packet.frame.arguments[0];
+ Assert.equal(arg1.class, "Object");
+
+ const objectFront = threadFront.pauseGrip(arg1);
+
+ const obj1 = (
+ await objectFront.getPropertyValue("obj1", null)
+ ).value.return.getGrip();
+ const obj2 = (
+ await objectFront.getPropertyValue("obj2", null)
+ ).value.return.getGrip();
+
+ info(`Retrieve "context" function reference`);
+ const context = (await objectFront.getPropertyValue("context", null)).value
+ .return;
+ info(`Retrieve "sum" function reference`);
+ const sum = (await objectFront.getPropertyValue("sum", null)).value.return;
+ info(`Retrieve "error" function reference`);
+ const error = (await objectFront.getPropertyValue("error", null)).value
+ .return;
+
+ assert_response(await context.apply(obj1, [obj1]), {
+ return: "correct context",
+ });
+ assert_response(await context.apply(obj2, [obj2]), {
+ return: "correct context",
+ });
+ assert_response(await context.apply(obj1, [obj2]), {
+ return: "wrong context",
+ });
+ assert_response(await context.apply(obj2, [obj1]), {
+ return: "wrong context",
+ });
+ // eslint-disable-next-line no-useless-call
+ assert_response(await sum.apply(null, [1, 2, 3, 4, 5, 6, 7]), {
+ return: 1 + 2 + 3 + 4 + 5 + 6 + 7,
+ });
+ // eslint-disable-next-line no-useless-call
+ assert_response(await error.apply(null, []), {
+ throw: "an error",
+ });
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ stopMe({
+ obj1: {},
+ obj2: {},
+ context(arg) {
+ return this === arg ? "correct context" : "wrong context";
+ },
+ sum(...parts) {
+ return parts.reduce((acc, v) => acc + v, 0);
+ },
+ error() {
+ throw "an error";
+ },
+ });
+ `);
+}
+
+function assert_response({ value }, expected) {
+ assert_completion(value, expected);
+}
+
+function assert_completion(value, expected) {
+ if (expected && "return" in expected) {
+ assert_value(value.return, expected.return);
+ }
+ if (expected && "throw" in expected) {
+ assert_value(value.throw, expected.throw);
+ }
+ if (!expected) {
+ assert_value(value, expected);
+ }
+}
+
+function assert_value(actual, expected) {
+ Assert.equal(typeof actual, typeof expected);
+
+ if (typeof expected === "object") {
+ // Note: We aren't using deepEqual here because we're only doing a cursory
+ // check of a few properties, not a full comparison of the result, since
+ // the full outputs includes stuff like preview info that we don't need.
+ for (const key of Object.keys(expected)) {
+ assert_value(actual[key], expected[key]);
+ }
+ } else {
+ Assert.equal(actual, expected);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-02.js b/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-02.js
new file mode 100644
index 0000000000..743286281c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-02.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const arg1 = packet.frame.arguments[0];
+ Assert.equal(arg1.class, "Object");
+
+ await threadFront.pauseGrip(arg1).threadGrip();
+ const obj = arg1;
+ await threadFront.resume();
+
+ const objectFront = threadFront.pauseGrip(obj);
+
+ const method = (await objectFront.getPropertyValue("method", null)).value
+ .return;
+
+ const methodCalled = method.apply(obj, []);
+
+ // Ensure that we actually paused at the `debugger;` line.
+ const packet2 = await waitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.frame.where.column, 8);
+
+ await threadFront.resume();
+ await methodCalled;
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ stopMe({
+ method(){
+ debugger;
+ },
+ });
+ `);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-03.js b/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-03.js
new file mode 100644
index 0000000000..6a3e919661
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-fn-apply-03.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const arg1 = packet.frame.arguments[0];
+ Assert.equal(arg1.class, "Object");
+
+ await threadFront.pauseGrip(arg1).threadGrip();
+ const obj = arg1;
+
+ const objectFront = threadFront.pauseGrip(obj);
+
+ const method = (await objectFront.getPropertyValue("method", null)).value
+ .return;
+
+ try {
+ await method.apply(obj, []);
+ Assert.ok(false, "expected exception");
+ } catch (err) {
+ Assert.ok(!!err.message.match(/debugee object is not callable/));
+ }
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ stopMe({
+ method: {},
+ });
+ `);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-nested-promise.js b/devtools/server/tests/xpcshell/test_objectgrips-nested-promise.js
new file mode 100644
index 0000000000..b60b7328c2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-nested-promise.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const [grip1, grip2] = packet.frame.arguments;
+ strictEqual(grip1.class, "Promise", "promise1 has a promise grip.");
+ strictEqual(grip2.class, "Promise", "promise2 has a promise grip.");
+
+ const objClient1 = threadFront.pauseGrip(grip1);
+ const objClient2 = threadFront.pauseGrip(grip2);
+ const { promiseState: state1 } = await objClient1.getPromiseState();
+ const { promiseState: state2 } = await objClient2.getPromiseState();
+
+ strictEqual(state1.state, "fulfilled", "promise1 was fulfilled.");
+ strictEqual(state1.value, objClient2, "promise1 fulfilled with promise2.");
+ ok(!state1.hasOwnProperty("reason"), "promise1 has no rejection reason.");
+
+ strictEqual(state2.state, "rejected", "promise2 was rejected.");
+ strictEqual(state2.reason, objClient1, "promise2 rejected with promise1.");
+ ok(!state2.hasOwnProperty("value"), "promise2 has no resolution value.");
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ var resolve;
+ var promise1 = new Promise(r => {resolve = r});
+ Object.setPrototypeOf(promise1, null);
+ var promise2 = Promise.reject(promise1);
+ promise2.catch(() => {});
+ Object.setPrototypeOf(promise2, null);
+ resolve(promise2);
+ stopMe(promise1, promise2);
+ `);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-nested-proxy.js b/devtools/server/tests/xpcshell/test_objectgrips-nested-proxy.js
new file mode 100644
index 0000000000..5b0667c055
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-nested-proxy.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const [grip] = packet.frame.arguments;
+ const objClient = threadFront.pauseGrip(grip);
+ const { proxyTarget, proxyHandler } = await objClient.getProxySlots();
+
+ strictEqual(grip.class, "Proxy", "Its a proxy grip.");
+ strictEqual(
+ proxyTarget.getGrip().class,
+ "Proxy",
+ "The target is also a proxy."
+ );
+ strictEqual(
+ proxyHandler.getGrip().class,
+ "Proxy",
+ "The handler is also a proxy."
+ );
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ var proxy = new Proxy({}, {});
+ for (let i = 0; i < 1e5; ++i)
+ proxy = new Proxy(proxy, proxy);
+ stopMe(proxy);
+ `);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-property-value-01.js b/devtools/server/tests/xpcshell/test_objectgrips-property-value-01.js
new file mode 100644
index 0000000000..69da96a741
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-property-value-01.js
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const arg1 = packet.frame.arguments[0];
+ Assert.equal(arg1.class, "Object");
+
+ const objFront = threadFront.pauseGrip(arg1);
+
+ const expectedValues = {
+ stringProp: {
+ return: "a value",
+ },
+ stringNormal: {
+ return: "a value",
+ },
+ stringAbrupt: {
+ throw: "a value",
+ },
+ objectNormal: {
+ return: {
+ _grip: {
+ type: "object",
+ class: "Object",
+ ownPropertyLength: 1,
+ preview: {
+ kind: "Object",
+ ownProperties: {
+ prop: {
+ value: 4,
+ },
+ },
+ },
+ },
+ },
+ },
+ objectAbrupt: {
+ throw: {
+ _grip: {
+ type: "object",
+ class: "Object",
+ ownPropertyLength: 1,
+ preview: {
+ kind: "Object",
+ ownProperties: {
+ prop: {
+ value: 4,
+ },
+ },
+ },
+ },
+ },
+ },
+ context: {
+ return: "correct context",
+ },
+ method: {
+ return: {
+ _grip: {
+ type: "object",
+ class: "Function",
+ name: "method",
+ },
+ },
+ },
+ };
+
+ for (const [key, expected] of Object.entries(expectedValues)) {
+ const { value } = await objFront.getPropertyValue(key, null);
+ assert_completion(value, expected);
+ }
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ var obj = {
+ stringProp: "a value",
+ get stringNormal(){
+ return "a value";
+ },
+ get stringAbrupt() {
+ throw "a value";
+ },
+ get objectNormal() {
+ return { prop: 4 };
+ },
+ get objectAbrupt() {
+ throw { prop: 4 };
+ },
+ get context(){
+ return this === obj ? "correct context" : "wrong context";
+ },
+ method() {
+ return "a value";
+ },
+ };
+ stopMe(obj);
+ `);
+}
+
+function assert_completion(value, expected) {
+ if (expected && "return" in expected) {
+ assert_value(value.return, expected.return);
+ }
+ if (expected && "throw" in expected) {
+ assert_value(value.throw, expected.throw);
+ }
+ if (!expected) {
+ assert_value(value, expected);
+ }
+}
+
+function assert_value(actual, expected) {
+ Assert.equal(typeof actual, typeof expected);
+
+ if (typeof expected === "object") {
+ // Note: We aren't using deepEqual here because we're only doing a cursory
+ // check of a few properties, not a full comparison of the result, since
+ // the full outputs includes stuff like preview info that we don't need.
+ for (const key of Object.keys(expected)) {
+ assert_value(actual[key], expected[key]);
+ }
+ } else {
+ Assert.equal(actual, expected);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-property-value-02.js b/devtools/server/tests/xpcshell/test_objectgrips-property-value-02.js
new file mode 100644
index 0000000000..bc7337128c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-property-value-02.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const arg1 = packet.frame.arguments[0];
+ Assert.equal(arg1.class, "Object");
+
+ const obj = threadFront.pauseGrip(arg1);
+ await obj.threadGrip();
+
+ const objClient = obj;
+ await threadFront.resume();
+
+ const objClientCalled = objClient.getPropertyValue("prop", null);
+
+ // Ensure that we actually paused at the `debugger;` line.
+ const packet2 = await waitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.frame.where.column, 8);
+
+ await threadFront.resume();
+ await objClientCalled;
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arg1) {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ stopMe({
+ get prop(){
+ debugger;
+ },
+ });
+ `);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-property-value-03.js b/devtools/server/tests/xpcshell/test_objectgrips-property-value-03.js
new file mode 100644
index 0000000000..e9b130db79
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-property-value-03.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const { frame } = packet;
+ try {
+ const grips = frame.arguments;
+ const objClient = threadFront.pauseGrip(grips[0]);
+ const classes = [
+ "Object",
+ "Object",
+ "Array",
+ "Boolean",
+ "Number",
+ "String",
+ ];
+ for (const [i, grip] of grips.entries()) {
+ Assert.equal(grip.class, classes[i]);
+ await check_getter(objClient, grip.actor, i);
+ }
+ await check_getter(objClient, null, 0);
+ await check_getter(objClient, "invalid receiver actorId", 0);
+ } finally {
+ await threadFront.resume();
+ }
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe() {
+ debugger;
+ }.toString()
+ );
+
+ debuggee.eval(`
+ var obj = {
+ get getter() {
+ return objects.indexOf(this);
+ },
+ };
+ var objects = [obj, {}, [], new Boolean(), new Number(), new String()];
+ stopMe(...objects);
+ `);
+}
+
+async function check_getter(objClient, receiverId, expected) {
+ const { value } = await objClient.getPropertyValue("getter", receiverId);
+ Assert.equal(value.return, expected);
+}
diff --git a/devtools/server/tests/xpcshell/test_objectgrips-sparse-array.js b/devtools/server/tests/xpcshell/test_objectgrips-sparse-array.js
new file mode 100644
index 0000000000..76a6b32f4b
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_objectgrips-sparse-array.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+});
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const [grip] = packet.frame.arguments;
+ await threadFront.resume();
+
+ strictEqual(grip.class, "Array", "The grip has an Array class");
+
+ const { items } = grip.preview;
+ strictEqual(items[0], null, "The empty slot has null as grip preview");
+ deepEqual(
+ items[1],
+ { type: "undefined" },
+ "The undefined value has grip value of type undefined"
+ );
+ })
+);
+
+function evalCode(debuggee) {
+ debuggee.eval(
+ function stopMe(arr) {
+ debugger;
+ }.toString()
+ );
+ debuggee.eval("stopMe([, undefined])");
+}
diff --git a/devtools/server/tests/xpcshell/test_pause_exceptions-01.js b/devtools/server/tests/xpcshell/test_pause_exceptions-01.js
new file mode 100644
index 0000000000..74bbae55c3
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pause_exceptions-01.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that setting pauseOnExceptions to true will cause the debuggee to pause
+ * when an exception is thrown.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, commands }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: false,
+ });
+ const packet = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet.why.type, "exception");
+ Assert.equal(packet.why.exception, 42);
+
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ /* eslint-disable no-throw-literal */
+ // prettier-ignore
+ debuggee.eval("(" + function () {
+ function stopMe() {
+ debugger;
+ throw 42;
+ }
+ try {
+ stopMe();
+ } catch (e) {}
+ } + ")()");
+ /* eslint-enable no-throw-literal */
+}
diff --git a/devtools/server/tests/xpcshell/test_pause_exceptions-02.js b/devtools/server/tests/xpcshell/test_pause_exceptions-02.js
new file mode 100644
index 0000000000..00631b071f
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pause_exceptions-02.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that setting pauseOnExceptions to true when the debugger isn't in a
+ * paused state will not cause the debuggee to pause when an exception is thrown.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, commands }) => {
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: false,
+ });
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ Assert.equal(packet.why.type, "exception");
+ Assert.equal(packet.why.exception, 42);
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ /* eslint-disable no-throw-literal */
+ // prettier-ignore
+ debuggee.eval("(" + function () { // 1
+ function stopMe() { // 2
+ throw 42; // 3
+ } // 4
+ try { // 5
+ stopMe(); // 6
+ } catch (e) {} // 7
+ } + ")()");
+}
diff --git a/devtools/server/tests/xpcshell/test_pause_exceptions-03.js b/devtools/server/tests/xpcshell/test_pause_exceptions-03.js
new file mode 100644
index 0000000000..4fb13f4cf9
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pause_exceptions-03.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that setting pauseOnExceptions to true will cause the debuggee to pause
+ * when an exception is thrown.
+ */
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, commands }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: false,
+ });
+ await resume(threadFront);
+ const paused = await waitForPause(threadFront);
+ Assert.equal(paused.why.type, "exception");
+ equal(paused.frame.where.line, 4, "paused at throw");
+
+ await resume(threadFront);
+ },
+ {
+ // Bug 1508289, exception tests fails in worker scope
+ doNotRunWorker: true,
+ }
+ )
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe() { // 2
+ debugger; // 3
+ throw 42; // 4
+ } // 5
+ try { // 6
+ stopMe(); // 7
+ } catch (e) {}`, // 8
+ debuggee,
+ "1.8",
+ "test_pause_exceptions-03.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_pause_exceptions-04.js b/devtools/server/tests/xpcshell/test_pause_exceptions-04.js
new file mode 100644
index 0000000000..6246b112e0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pause_exceptions-04.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { waitForTick } = require("resource://devtools/shared/DevToolsUtils.js");
+
+/**
+ * Test that setting pauseOnExceptions to true and then to false will not cause
+ * the debuggee to pause when an exception is thrown.
+ */
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, client, debuggee, commands }) => {
+ let onResume = null;
+ let packet = null;
+
+ threadFront.once("paused", function (pkt) {
+ packet = pkt;
+ onResume = threadFront.resume();
+ });
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: true,
+ });
+
+ await evaluateTestCode(debuggee, "42");
+
+ await onResume;
+
+ Assert.equal(!!packet, true);
+ Assert.equal(packet.why.type, "exception");
+ Assert.equal(packet.why.exception, "42");
+ packet = null;
+
+ threadFront.once("paused", function (pkt) {
+ packet = pkt;
+ onResume = threadFront.resume();
+ });
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: false,
+ ignoreCaughtExceptions: true,
+ });
+
+ await evaluateTestCode(debuggee, "43");
+
+ // Test that the paused listener callback hasn't been called
+ // on the thrown error from dontStopMe()
+ Assert.equal(!!packet, false);
+
+ await commands.threadConfigurationCommand.updateConfiguration({
+ pauseOnExceptions: true,
+ ignoreCaughtExceptions: true,
+ });
+
+ await evaluateTestCode(debuggee, "44");
+
+ await onResume;
+
+ // Test that the paused listener callback has been called
+ // on the thrown error from stopMeAgain()
+ Assert.equal(!!packet, true);
+ Assert.equal(packet.why.type, "exception");
+ Assert.equal(packet.why.exception, "44");
+ },
+ {
+ // Bug 1508289, exception tests fails in worker scope
+ doNotRunWorker: true,
+ }
+ )
+);
+
+async function evaluateTestCode(debuggee, throwValue) {
+ await waitForTick();
+ try {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMeAgain() { // 2
+ throw ${throwValue}; // 3
+ } // 4
+ stopMeAgain(); // 5
+ `, // 6
+ debuggee,
+ "1.8",
+ "test_pause_exceptions-04.js",
+ 1
+ );
+ } catch (e) {}
+}
diff --git a/devtools/server/tests/xpcshell/test_pauselifetime-01.js b/devtools/server/tests/xpcshell/test_pauselifetime-01.js
new file mode 100644
index 0000000000..db20a02521
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pauselifetime-01.js
@@ -0,0 +1,54 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that pause-lifetime grips go away correctly after a resume.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const pauseActor = packet.actor;
+
+ // Make a bogus request to the pause-lifetime actor. Should get
+ // unrecognized-packet-type (and not no-such-actor).
+ try {
+ await client.request({ to: pauseActor, type: "bogusRequest" });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ ok(true, "bogusRequest thrown");
+ Assert.equal(e.error, "unrecognizedPacketType");
+ }
+
+ await threadFront.resume();
+
+ // Now that we've resumed, should get no-such-actor for the
+ // same request.
+ try {
+ await client.request({ to: pauseActor, type: "bogusRequest" });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ ok(true, "bogusRequest thrown");
+ Assert.equal(e.error, "noSuchActor");
+ }
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe() {
+ debugger;
+ }
+ stopMe();
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_pauselifetime-02.js b/devtools/server/tests/xpcshell/test_pauselifetime-02.js
new file mode 100644
index 0000000000..e936df6177
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pauselifetime-02.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that pause-lifetime grips go away correctly after a resume.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const args = packet.frame.arguments;
+ const objActor = args[0].actor;
+ Assert.equal(args[0].class, "Object");
+ Assert.ok(!!objActor);
+
+ // Make a bogus request to the grip actor. Should get
+ // unrecognized-packet-type (and not no-such-actor).
+ try {
+ await client.request({ to: objActor, type: "bogusRequest" });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ ok(true, "bogusRequest thrown");
+ Assert.equal(e.error, "unrecognizedPacketType");
+ }
+
+ await threadFront.resume();
+
+ // Now that we've resumed, should get no-such-actor for the
+ // same request.
+ try {
+ await client.request({ to: objActor, type: "bogusRequest" });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ ok(true, "bogusRequest thrown");
+ Assert.equal(e.error, "noSuchActor");
+ }
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(obj) {
+ debugger;
+ }
+ stopMe({ foo: "bar" });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_pauselifetime-03.js b/devtools/server/tests/xpcshell/test_pauselifetime-03.js
new file mode 100644
index 0000000000..558ac8b910
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pauselifetime-03.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that pause-lifetime grip clients are marked invalid after a resume.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const args = packet.frame.arguments;
+ const objActor = args[0].actor;
+ Assert.equal(args[0].class, "Object");
+ Assert.ok(!!objActor);
+
+ const objectFront = threadFront.pauseGrip(args[0]);
+ Assert.ok(objectFront.valid);
+
+ // Make a bogus request to the grip actor. Should get
+ // unrecognized-packet-type (and not no-such-actor).
+ try {
+ const objFront = client.getFrontByID(objActor);
+ await objFront.request({ to: objActor, type: "bogusRequest" });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ ok(true, "bogusRequest thrown");
+ Assert.ok(!!e.message.match(/unrecognizedPacketType/));
+ }
+ Assert.ok(objectFront.valid);
+
+ await threadFront.resume();
+
+ // Now that we've resumed, should get no-such-actor for the
+ // same request.
+ try {
+ const objFront = client.getFrontByID(objActor);
+ await objFront.request({ to: objActor, type: "bogusRequest" });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ ok(true, "bogusRequest thrown");
+ Assert.ok(!!e.message.match(/noSuchActor/));
+ }
+ Assert.ok(!objectFront.valid);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(obj) {
+ debugger;
+ }
+ stopMe({ foo: "bar" });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_pauselifetime-04.js b/devtools/server/tests/xpcshell/test_pauselifetime-04.js
new file mode 100644
index 0000000000..7d226260f0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_pauselifetime-04.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that requesting a pause actor for the same value multiple
+ * times returns the same actor.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const args = packet.frame.arguments;
+ const objActor1 = args[0].actor;
+
+ const response = await threadFront.getFrames(0, 1);
+ const frame = response.frames[0];
+ Assert.equal(objActor1, frame.arguments[0].actor);
+
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(obj) {
+ debugger;
+ }
+ stopMe({ foo: "bar" });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_promise_state-01.js b/devtools/server/tests/xpcshell/test_promise_state-01.js
new file mode 100644
index 0000000000..d02b64a67e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_promise_state-01.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Test that the preview in a Promise's grip is correct when the Promise is
+ * pending.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const environment = await packet.frame.getEnvironment();
+ const grip = environment.bindings.variables.p.value;
+
+ ok(grip.preview);
+ equal(grip.class, "Promise");
+ equal(grip.preview.ownProperties["<state>"].value, "pending");
+
+ const objClient = threadFront.pauseGrip(grip);
+ const { promiseState } = await objClient.getPromiseState();
+ equal(promiseState.state, "pending");
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable mozilla/var-only-at-top-level, no-unused-vars */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "doTest();\n" +
+ function doTest() {
+ var p = new Promise(function () {});
+ debugger;
+ },
+ debuggee
+ );
+ /* eslint-enable mozilla/var-only-at-top-level, no-unused-vars */
+}
diff --git a/devtools/server/tests/xpcshell/test_promise_state-02.js b/devtools/server/tests/xpcshell/test_promise_state-02.js
new file mode 100644
index 0000000000..e1219f545c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_promise_state-02.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Test that the preview in a Promise's grip is correct when the Promise is
+ * fulfilled.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const environment = await packet.frame.getEnvironment();
+ const grip = environment.bindings.variables.p.value;
+
+ ok(grip.preview);
+ equal(grip.class, "Promise");
+ equal(grip.preview.ownProperties["<state>"].value, "fulfilled");
+ equal(
+ grip.preview.ownProperties["<value>"].value.actorID,
+ packet.frame.arguments[0].actorID,
+ "The promise's fulfilled state value in the preview should be the same " +
+ "value passed to the then function"
+ );
+
+ const objClient = threadFront.pauseGrip(grip);
+ const { promiseState } = await objClient.getPromiseState();
+ equal(promiseState.state, "fulfilled");
+ equal(
+ promiseState.value.getGrip().actorID,
+ packet.frame.arguments[0].actorID,
+ "The promise's fulfilled state value in getPromiseState() should be " +
+ "the same value passed to the then function"
+ );
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable mozilla/var-only-at-top-level, no-unused-vars */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "doTest();\n" +
+ function doTest() {
+ var resolved = Promise.resolve({});
+ resolved.then(() => {
+ var p = resolved;
+ debugger;
+ });
+ },
+ debuggee
+ );
+ /* eslint-enable mozilla/var-only-at-top-level, no-unused-vars */
+}
diff --git a/devtools/server/tests/xpcshell/test_promise_state-03.js b/devtools/server/tests/xpcshell/test_promise_state-03.js
new file mode 100644
index 0000000000..8ec1fa3717
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_promise_state-03.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Test that the preview in a Promise's grip is correct when the Promise is
+ * rejected.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ const environment = await packet.frame.getEnvironment();
+ const grip = environment.bindings.variables.p.value;
+ ok(grip.preview);
+ equal(grip.class, "Promise");
+ equal(grip.preview.ownProperties["<state>"].value, "rejected");
+ equal(
+ grip.preview.ownProperties["<reason>"].value.actorID,
+ packet.frame.arguments[0].actorID,
+ "The promise's rejected state reason in the preview should be the same " +
+ "value passed to the then function"
+ );
+
+ const objClient = threadFront.pauseGrip(grip);
+ const { promiseState } = await objClient.getPromiseState();
+ equal(promiseState.state, "rejected");
+ equal(
+ promiseState.reason.getGrip().actorID,
+ packet.frame.arguments[0].actorID,
+ "The promise's rejected state value in getPromiseState() should be " +
+ "the same value passed to the then function"
+ );
+ })
+);
+
+function evalCode(debuggee) {
+ /* eslint-disable mozilla/var-only-at-top-level, no-unused-vars */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "doTest();\n" +
+ function doTest() {
+ var resolved = Promise.reject(new Error("uh oh"));
+ resolved.catch(() => {
+ var p = resolved;
+ debugger;
+ });
+ },
+ debuggee
+ );
+ /* eslint-enable mozilla/var-only-at-top-level, no-unused-vars */
+}
diff --git a/devtools/server/tests/xpcshell/test_promises_run_to_completion.js b/devtools/server/tests/xpcshell/test_promises_run_to_completion.js
new file mode 100644
index 0000000000..4d1e8745fe
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_promises_run_to_completion.js
@@ -0,0 +1,132 @@
+// Bug 1145201: Promise then-handlers can still be executed while the debugger is paused.
+//
+// When a promise is resolved, for each of its callbacks, a microtask is queued
+// to run the callback. At various points, the HTML spec says the browser must
+// "perform a microtask checkpoint", which means to draw microtasks from the
+// queue and run them, until the queue is empty.
+//
+// The HTML spec is careful to perform a microtask checkpoint directly after
+// each invocation of an event handler or DOM callback, so that code using
+// promises can trust that its promise callbacks run promptly, in a
+// deterministic order, without DOM events or other outside influences
+// intervening.
+//
+// When the JavaScript debugger interrupts the execution of debuggee content
+// code, it naturally must process events for its own user interface and promise
+// callbacks. However, it must not run any debuggee microtasks. The debuggee has
+// been interrupted in the midst of executing some other code, and the
+// JavaScript spec promises developers: "Once execution of a Job is initiated,
+// the Job always executes to completion. No other Job may be initiated until
+// the currently running Job completes." [1] This promise would be broken if the
+// debugger's own event processing ran debuggee microtasks during the
+// interruption.
+//
+// Looking at things from the other side, a microtask checkpoint must be
+// performed before returning from a debugger callback, rather than being put
+// off until the debuggee performs its next microtask checkpoint, so that
+// debugger microtasks are not interleaved with debuggee microtasks. A debuggee
+// microtask could hit a breakpoint or otherwise re-enter the debugger, which
+// might be quite surprised to see a new debugger callback begin before its
+// previous promise callbacks could finish.
+//
+// [1]: https://www.ecma-international.org/ecma-262/9.0/index.html#sec-jobs-and-job-queues
+
+"use strict";
+
+const Debugger = require("Debugger");
+
+function test_promises_run_to_completion() {
+ const g = createTestGlobal(
+ "test global for test_promises_run_to_completion.js"
+ );
+ const dbg = new Debugger(g);
+ g.Assert = Assert;
+ const log = [""];
+ g.log = log;
+
+ dbg.onDebuggerStatement = function handleDebuggerStatement(frame) {
+ dbg.onDebuggerStatement = undefined;
+
+ // Exercise the promise machinery: resolve a promise and perform a microtask
+ // queue. When called from a debugger hook, the debuggee's microtasks should not
+ // run.
+ log[0] += "debug-handler(";
+ Promise.resolve(42).then(v => {
+ Assert.equal(
+ v,
+ 42,
+ "debugger callback promise handler got the right value"
+ );
+ log[0] += "debug-react";
+ });
+ log[0] += "(";
+ force_microtask_checkpoint();
+ log[0] += ")";
+
+ Promise.resolve(42).then(v => {
+ // The microtask running this callback should be handled as we leave the
+ // onDebuggerStatement Debugger callback, and should not be interleaved
+ // with debuggee microtasks.
+ log[0] += "(trailing)";
+ });
+
+ log[0] += ")";
+ };
+
+ // Evaluate some debuggee code that resolves a promise, and then enters the debugger.
+ Cu.evalInSandbox(
+ `
+ log[0] += "eval(";
+ Promise.resolve(42).then(function debuggeePromiseCallback(v) {
+ Assert.equal(v, 42, "debuggee promise handler got the right value");
+ // Debugger microtask checkpoints must not run debuggee microtasks, so
+ // this callback should run at the next microtask checkpoint *not*
+ // performed by the debugger.
+ log[0] += "eval-react";
+ });
+
+ log[0] += "debugger(";
+ debugger;
+ log[0] += "))";
+ `,
+ g
+ );
+
+ // Let other microtasks run. This should run the debuggee's promise callback.
+ log[0] += "final(";
+ force_microtask_checkpoint();
+ log[0] += ")";
+
+ Assert.equal(
+ log[0],
+ `\
+eval(\
+debugger(\
+debug-handler(\
+(debug-react)\
+)\
+(trailing)\
+))\
+final(\
+eval-react\
+)`,
+ "microtasks ran as expected"
+ );
+
+ run_next_test();
+}
+
+function force_microtask_checkpoint() {
+ // Services.tm.spinEventLoopUntilEmpty only performs a microtask checkpoint if
+ // there is actually an event to run. So make one up.
+ let ran = false;
+ Services.tm.dispatchToMainThread(() => {
+ ran = true;
+ });
+ Services.tm.spinEventLoopUntil(
+ "Test(test_promises_run_to_completion.js:force_microtask_checkpoint)",
+ () => ran
+ );
+}
+
+add_test(test_promises_run_to_completion);
diff --git a/devtools/server/tests/xpcshell/test_register_actor.js b/devtools/server/tests/xpcshell/test_register_actor.js
new file mode 100644
index 0000000000..f38ab73572
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_register_actor.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ // Allow incoming connections.
+ DevToolsServer.keepAlive = true;
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ add_test(test_lazy_api);
+ add_test(manual_remove);
+ add_test(cleanup);
+ run_next_test();
+}
+
+// Bug 988237: Test the new lazy actor actor-register
+function test_lazy_api() {
+ let isActorLoaded = false;
+ let isActorInstantiated = false;
+ function onActorEvent(subject, topic, data) {
+ if (data == "loaded") {
+ isActorLoaded = true;
+ } else if (data == "instantiated") {
+ isActorInstantiated = true;
+ }
+ }
+ Services.obs.addObserver(onActorEvent, "actor");
+ ActorRegistry.registerModule("xpcshell-test/registertestactors-lazy", {
+ prefix: "lazy",
+ constructor: "LazyActor",
+ type: { global: true, target: true },
+ });
+ // The actor is immediatly registered, but not loaded
+ Assert.ok(
+ ActorRegistry.targetScopedActorFactories.hasOwnProperty("lazyActor")
+ );
+ Assert.ok(ActorRegistry.globalActorFactories.hasOwnProperty("lazyActor"));
+ Assert.ok(!isActorLoaded);
+ Assert.ok(!isActorInstantiated);
+
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ client.connect().then(function onConnect() {
+ client.mainRoot.rootForm.then(onRootForm);
+ });
+ function onRootForm(response) {
+ // On rootForm, the actor is still not loaded,
+ // but we can see its name in the list of available actors
+ Assert.ok(!isActorLoaded);
+ Assert.ok(!isActorInstantiated);
+ Assert.ok("lazyActor" in response);
+
+ const { LazyFront } = require("xpcshell-test/registertestactors-lazy");
+ const front = new LazyFront(client);
+ // As this Front isn't instantiated by protocol.js, we have to manually
+ // set its actor ID and manage it:
+ front.actorID = response.lazyActor;
+ client.addActorPool(front);
+ front.manage(front);
+
+ front.hello().then(onRequest);
+ }
+ function onRequest(response) {
+ Assert.equal(response, "world");
+
+ // Finally, the actor is loaded on the first request being made to it
+ Assert.ok(isActorLoaded);
+ Assert.ok(isActorInstantiated);
+
+ Services.obs.removeObserver(onActorEvent, "actor");
+ client.close().then(() => run_next_test());
+ }
+}
+
+function manual_remove() {
+ Assert.ok(ActorRegistry.globalActorFactories.hasOwnProperty("lazyActor"));
+ ActorRegistry.removeGlobalActor("lazyActor");
+ Assert.ok(!ActorRegistry.globalActorFactories.hasOwnProperty("lazyActor"));
+
+ run_next_test();
+}
+
+function cleanup() {
+ DevToolsServer.destroy();
+
+ // Check that all actors are unregistered on server destruction
+ Assert.ok(
+ !ActorRegistry.targetScopedActorFactories.hasOwnProperty("lazyActor")
+ );
+ Assert.ok(!ActorRegistry.globalActorFactories.hasOwnProperty("lazyActor"));
+
+ run_next_test();
+}
diff --git a/devtools/server/tests/xpcshell/test_requestTypes.js b/devtools/server/tests/xpcshell/test_requestTypes.js
new file mode 100644
index 0000000000..8787ae5f85
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_requestTypes.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { rootSpec } = require("resource://devtools/shared/specs/root.js");
+const {
+ generateRequestTypes,
+} = require("resource://devtools/shared/protocol/Actor.js");
+
+add_task(async function () {
+ DevToolsServer.init();
+ DevToolsServer.registerAllActors();
+
+ const client = new DevToolsClient(DevToolsServer.connectPipe());
+ await client.connect();
+
+ const response = await client.mainRoot.requestTypes();
+ const expectedRequestTypes = Object.keys(generateRequestTypes(rootSpec));
+
+ Assert.ok(Array.isArray(response.requestTypes));
+ Assert.equal(
+ JSON.stringify(response.requestTypes),
+ JSON.stringify(expectedRequestTypes)
+ );
+
+ await client.close();
+});
diff --git a/devtools/server/tests/xpcshell/test_restartFrame-01.js b/devtools/server/tests/xpcshell/test_restartFrame-01.js
new file mode 100644
index 0000000000..cb13ae2d7e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_restartFrame-01.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check restarting a frame and stepping out of the
+ * restarted frame.
+ */
+
+async function testFinish({ threadFront, devToolsClient }) {
+ await close(devToolsClient);
+
+ do_test_finished();
+}
+
+async function invokeAndPause({ global, threadFront }, expression) {
+ return executeOnNextTickAndWaitForPause(
+ () => Cu.evalInSandbox(expression, global),
+ threadFront
+ );
+}
+
+async function steps(threadFront, sequence) {
+ const locations = [];
+ for (const cmd of sequence) {
+ const packet = await step(threadFront, cmd);
+ locations.push(getPauseLocation(packet));
+ }
+ return locations;
+}
+
+async function step(threadFront, cmd) {
+ return cmd(threadFront);
+}
+
+function getPauseLocation(packet) {
+ const { line, column } = packet.frame.where;
+ return { line, column };
+}
+
+async function restartFrame0(dbg, func, expectedLocation) {
+ const { threadFront } = dbg;
+
+ info("pause and step into a()");
+ await invokeAndPause(dbg, `${func}()`);
+ await steps(threadFront, [stepOver, stepIn]);
+
+ info("restart the youngest frame a()");
+ const { frames } = await threadFront.frames(0, 5);
+ const frameActorID = frames[0].actorID;
+ const packet = await restartFrame(threadFront, frameActorID);
+
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ "pause location in the restarted frame a()"
+ );
+}
+
+async function restartFrame1(dbg, func, expectedLocation) {
+ const { threadFront } = dbg;
+
+ info("pause and step into b()");
+ await invokeAndPause(dbg, `${func}()`);
+ await steps(threadFront, [stepOver, stepIn, stepIn]);
+
+ info("restart the frame with index 1");
+ const { frames } = await threadFront.frames(0, 5);
+ const frameActorID = frames[1].actorID;
+ const packet = await restartFrame(threadFront, frameActorID);
+
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ "pause location in the restarted frame c()"
+ );
+}
+
+async function stepOutRestartedFrame(
+ dbg,
+ restartedFrameName,
+ expectedLocation,
+ expectedCallstackLength
+) {
+ const { threadFront } = dbg;
+ const { frames } = await threadFront.frames(0, 5);
+
+ Assert.equal(
+ frames.length,
+ expectedCallstackLength,
+ `the callstack length after restarting frame ${restartedFrameName}()`
+ );
+
+ info(`step out of the restarted frame ${restartedFrameName}()`);
+ const frameActorID = frames[0].actorID;
+ const packet = await stepOut(threadFront, frameActorID);
+
+ deepEqual(getPauseLocation(packet), expectedLocation, `step out location`);
+}
+
+function run_test() {
+ return (async function () {
+ const dbg = await setupTestFromUrl("stepping.js");
+
+ info(`Test restarting the youngest frame`);
+ await restartFrame0(dbg, "arithmetic", { line: 7, column: 2 });
+ await stepOutRestartedFrame(dbg, "a", { line: 16, column: 8 }, 3);
+ await dbg.threadFront.resume();
+
+ info(`Test restarting the frame with the index 1`);
+ await restartFrame1(dbg, "nested", { line: 30, column: 2 });
+ await stepOutRestartedFrame(dbg, "c", { line: 36, column: 0 }, 3);
+ await dbg.threadFront.resume();
+
+ await testFinish(dbg);
+ })();
+}
diff --git a/devtools/server/tests/xpcshell/test_safe-getter.js b/devtools/server/tests/xpcshell/test_safe-getter.js
new file mode 100644
index 0000000000..65bf3414ea
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_safe-getter.js
@@ -0,0 +1,54 @@
+/* eslint-disable strict */
+function run_test() {
+ Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
+ });
+ const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+ );
+ addDebuggerToGlobal(globalThis);
+ const g = createTestGlobal("test", {
+ wantGlobalProperties: ["ChromeUtils"],
+ });
+ const dbg = new Debugger();
+ const gw = dbg.addDebuggee(g);
+
+ g.eval(`
+ // This is not a CCW.
+ Object.defineProperty(this, "bar", {
+ get: function() { return "bar"; },
+ configurable: true,
+ enumerable: true
+ });
+
+ const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+ );
+
+ // This is a CCW.
+ XPCOMUtils.defineLazyScriptGetter(
+ this, "foo", "chrome://global/content/viewZoomOverlay.js");
+ `);
+
+ // Neither scripted getter should be considered safe.
+ assert(!DevToolsUtils.hasSafeGetter(gw.getOwnPropertyDescriptor("bar")));
+ assert(!DevToolsUtils.hasSafeGetter(gw.getOwnPropertyDescriptor("foo")));
+
+ // Create an object in a less privileged sandbox.
+ const obj = gw.makeDebuggeeValue(
+ Cu.waiveXrays(
+ Cu.Sandbox(null).eval(`
+ Object.defineProperty({}, "bar", {
+ get: function() { return "bar"; },
+ configurable: true,
+ enumerable: true
+ });
+ `)
+ )
+ );
+
+ // After waiving Xrays, the object has 2 wrappers. Both must be removed
+ // in order to detect that the getter is not safe.
+ assert(!DevToolsUtils.hasSafeGetter(obj.getOwnPropertyDescriptor("bar")));
+}
diff --git a/devtools/server/tests/xpcshell/test_sessionDataHelpers.js b/devtools/server/tests/xpcshell/test_sessionDataHelpers.js
new file mode 100644
index 0000000000..e0dcc3b21b
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_sessionDataHelpers.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test SessionDataHelpers.
+ */
+
+"use strict";
+
+const { SessionDataHelpers } = ChromeUtils.import(
+ "resource://devtools/server/actors/watcher/SessionDataHelpers.jsm"
+);
+const { SUPPORTED_DATA } = SessionDataHelpers;
+const { TARGETS } = SUPPORTED_DATA;
+
+function run_test() {
+ const sessionData = {
+ [TARGETS]: [],
+ };
+
+ info("Test adding a new entry");
+ SessionDataHelpers.addOrSetSessionDataEntry(
+ sessionData,
+ TARGETS,
+ ["frame", "worker"],
+ "add"
+ );
+ deepEqual(
+ sessionData[TARGETS],
+ ["frame", "worker"],
+ "the two elements were added"
+ );
+
+ info("Test adding a duplicated entry");
+ SessionDataHelpers.addOrSetSessionDataEntry(
+ sessionData,
+ TARGETS,
+ ["frame"],
+ "add"
+ );
+ deepEqual(
+ sessionData[TARGETS],
+ ["frame", "worker"],
+ "addOrSetSessionDataEntry ignore duplicates"
+ );
+
+ SessionDataHelpers.addOrSetSessionDataEntry(
+ sessionData,
+ TARGETS,
+ ["process"],
+ "add"
+ );
+ deepEqual(
+ sessionData[TARGETS],
+ ["frame", "worker", "process"],
+ "the third element is added"
+ );
+
+ info("Test removing an existing entry");
+ let removed = SessionDataHelpers.removeSessionDataEntry(
+ sessionData,
+ TARGETS,
+ ["process"]
+ );
+ ok(removed, "removedSessionDataEntry returned true as it removed an element");
+ deepEqual(
+ sessionData[TARGETS],
+ ["frame", "worker"],
+ "the element has been remove"
+ );
+
+ info("Test removing non-existing entry");
+ removed = SessionDataHelpers.removeSessionDataEntry(sessionData, TARGETS, [
+ "not-existing",
+ ]);
+ ok(
+ !removed,
+ "removedSessionDataEntry returned false as no element has been removed"
+ );
+ deepEqual(
+ sessionData[TARGETS],
+ ["frame", "worker"],
+ "no change made to the array"
+ );
+
+ removed = SessionDataHelpers.removeSessionDataEntry(sessionData, TARGETS, [
+ "frame",
+ "worker",
+ ]);
+ ok(
+ removed,
+ "removedSessionDataEntry returned true as elements have been removed"
+ );
+ deepEqual(sessionData[TARGETS], [], "all elements were removed");
+
+ info("Test settting instead of adding data entries");
+ SessionDataHelpers.addOrSetSessionDataEntry(
+ sessionData,
+ TARGETS,
+ ["frame"],
+ "add"
+ );
+ deepEqual(sessionData[TARGETS], ["frame"], "frame was re-added");
+
+ SessionDataHelpers.addOrSetSessionDataEntry(
+ sessionData,
+ TARGETS,
+ ["process", "worker"],
+ "set"
+ );
+ deepEqual(
+ sessionData[TARGETS],
+ ["process", "worker"],
+ "frame was replaced by process and worker"
+ );
+
+ info("Test setting an empty array");
+ SessionDataHelpers.addOrSetSessionDataEntry(sessionData, TARGETS, [], "set");
+ deepEqual(
+ sessionData[TARGETS],
+ [],
+ "Setting an empty array of entries clears the data entry"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-at-the-beginning-of-a-minified-fn.js b/devtools/server/tests/xpcshell/test_setBreakpoint-at-the-beginning-of-a-minified-fn.js
new file mode 100644
index 0000000000..9140e92d7c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-at-the-beginning-of-a-minified-fn.js
@@ -0,0 +1,41 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-column-minified.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScript(SOURCE_URL, debuggee);
+ const { source } = await promise;
+
+ // Pause inside of the nested function so we can make sure that we don't
+ // add any other breakpoints at other places on this line.
+ const location = { sourceUrl: source.url, line: 3, column: 56 };
+ setBreakpoint(threadFront, location);
+
+ const packet = await executeOnNextTickAndWaitForPause(function () {
+ Cu.evalInSandbox("f()", debuggee);
+ }, threadFront);
+
+ const why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, location.line);
+ Assert.equal(where.column, 56);
+
+ const environment = await packet.frame.getEnvironment();
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value.type, "undefined");
+ Assert.equal(variables.b.value.type, "undefined");
+ Assert.equal(variables.c.value.type, "undefined");
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-at-the-end-of-a-minified-fn.js b/devtools/server/tests/xpcshell/test_setBreakpoint-at-the-end-of-a-minified-fn.js
new file mode 100644
index 0000000000..f9df5adad4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-at-the-end-of-a-minified-fn.js
@@ -0,0 +1,41 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-column-minified.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScript(SOURCE_URL, debuggee);
+ const { source } = await promise;
+
+ // Pause inside of the nested function so we can make sure that we don't
+ // add any other breakpoints at other places on this line.
+ const location = { sourceUrl: source.url, line: 3, column: 81 };
+ setBreakpoint(threadFront, location);
+
+ const packet = await executeOnNextTickAndWaitForPause(function () {
+ Cu.evalInSandbox("f()", debuggee);
+ }, threadFront);
+
+ const why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, location.line);
+ Assert.equal(where.column, 81);
+
+ const environment = await packet.frame.getEnvironment();
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value, 1);
+ Assert.equal(variables.b.value, 2);
+ Assert.equal(variables.c.value, 3);
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-column-in-gcd-script.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-column-in-gcd-script.js
new file mode 100644
index 0000000000..797cb6cd65
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-column-in-gcd-script.js
@@ -0,0 +1,46 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-column-in-gcd-script.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, targetFront }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScriptWithOptions(SOURCE_URL, {
+ target: debuggee,
+ ignoreCache: true,
+ });
+ Cu.forceGC();
+ Cu.forceGC();
+ Cu.forceGC();
+
+ const { source } = await promise;
+
+ const location = { sourceUrl: source.url, line: 6, column: 21 };
+ setBreakpoint(threadFront, location);
+
+ const packet = await executeOnNextTickAndWaitForPause(function () {
+ reload(targetFront).then(function () {
+ loadSubScriptWithOptions(SOURCE_URL, {
+ target: debuggee,
+ ignoreCache: true,
+ });
+ });
+ }, threadFront);
+ const environment = await packet.frame.getEnvironment();
+ const why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.line, location.line);
+ Assert.equal(where.column, location.column);
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value, 1);
+ Assert.equal(variables.b.value.type, "undefined");
+ Assert.equal(variables.c.value.type, "undefined");
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-column.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-column.js
new file mode 100644
index 0000000000..200d8b44e6
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-column.js
@@ -0,0 +1,36 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-column.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScript(SOURCE_URL, debuggee);
+ const { source } = await promise;
+
+ const location = { sourceUrl: source.url, line: 4, column: 21 };
+ setBreakpoint(threadFront, location);
+
+ const packet = await executeOnNextTickAndWaitForPause(function () {
+ Cu.evalInSandbox("f()", debuggee);
+ }, threadFront);
+ const environment = await packet.frame.getEnvironment();
+ const why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, location.line);
+ Assert.equal(where.column, location.column);
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value, 1);
+ Assert.equal(variables.b.value.type, "undefined");
+ Assert.equal(variables.c.value.type, "undefined");
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-in-gcd-script.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-in-gcd-script.js
new file mode 100644
index 0000000000..565402551e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-in-gcd-script.js
@@ -0,0 +1,45 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-line-in-gcd-script.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, targetFront }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScriptWithOptions(SOURCE_URL, {
+ target: debuggee,
+ ignoreCache: true,
+ });
+ Cu.forceGC();
+ Cu.forceGC();
+ Cu.forceGC();
+
+ const { source } = await promise;
+
+ const location = { sourceUrl: source.url, line: 7 };
+ setBreakpoint(threadFront, location);
+
+ const packet = await executeOnNextTickAndWaitForPause(function () {
+ reload(targetFront).then(function () {
+ loadSubScriptWithOptions(SOURCE_URL, {
+ target: debuggee,
+ ignoreCache: true,
+ });
+ });
+ }, threadFront);
+ const why = packet.why;
+ const environment = await packet.frame.getEnvironment();
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.line, location.line);
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value, 1);
+ Assert.equal(variables.b.value.type, "undefined");
+ Assert.equal(variables.c.value.type, "undefined");
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-offsets.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-offsets.js
new file mode 100644
index 0000000000..2debc26b93
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-offsets.js
@@ -0,0 +1,52 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-line-with-multiple-offsets.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScript(SOURCE_URL, debuggee);
+ const { source } = await promise;
+ const sourceFront = threadFront.source(source);
+
+ const location = { sourceUrl: sourceFront.url, line: 4 };
+ setBreakpoint(threadFront, location);
+
+ let packet = await executeOnNextTickAndWaitForPause(function () {
+ Cu.evalInSandbox("f()", debuggee);
+ }, threadFront);
+ let why = packet.why;
+ let environment = await packet.frame.getEnvironment();
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ let frame = packet.frame;
+ let where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, location.line);
+ let variables = environment.bindings.variables;
+ Assert.equal(variables.i.value.type, "undefined");
+
+ const location2 = { sourceUrl: sourceFront.url, line: 7 };
+ setBreakpoint(threadFront, location2);
+
+ packet = await executeOnNextTickAndWaitForPause(
+ () => resume(threadFront),
+ threadFront
+ );
+ environment = await packet.frame.getEnvironment();
+ why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ frame = packet.frame;
+ where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, location2.line);
+ variables = environment.bindings.variables;
+ Assert.equal(variables.i.value, 1);
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-statements.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-statements.js
new file mode 100644
index 0000000000..f5ec75a353
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-multiple-statements.js
@@ -0,0 +1,38 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl(
+ "setBreakpoint-on-line-with-multiple-statements.js"
+);
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScript(SOURCE_URL, debuggee);
+ const { source } = await promise;
+ const sourceFront = threadFront.source(source);
+
+ const location = { sourceUrl: sourceFront.url, line: 4 };
+ setBreakpoint(threadFront, location);
+
+ const packet = await executeOnNextTickAndWaitForPause(function () {
+ Cu.evalInSandbox("f()", debuggee);
+ }, threadFront);
+ const why = packet.why;
+ const environment = await packet.frame.getEnvironment();
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, location.line);
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value.type, "undefined");
+ Assert.equal(variables.b.value.type, "undefined");
+ Assert.equal(variables.c.value.type, "undefined");
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js
new file mode 100644
index 0000000000..1bcdadbe4a
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl(
+ "setBreakpoint-on-line-with-no-offsets-in-gcd-script.js"
+);
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, targetFront }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScriptWithOptions(SOURCE_URL, {
+ target: debuggee,
+ ignoreCache: true,
+ });
+ Cu.forceGC();
+ Cu.forceGC();
+ Cu.forceGC();
+
+ const { source } = await promise;
+ const sourceFront = threadFront.source(source);
+
+ const location = { line: 7 };
+ let [packet, breakpointClient] = await setBreakpoint(
+ sourceFront,
+ location
+ );
+ Assert.ok(packet.isPending);
+ Assert.equal(false, "actualLocation" in packet);
+
+ packet = await executeOnNextTickAndWaitForPause(function () {
+ reload(targetFront).then(function () {
+ loadSubScriptWithOptions(SOURCE_URL, {
+ target: debuggee,
+ ignoreCache: true,
+ });
+ });
+ }, threadFront);
+ const environment = await packet.frame.getEnvironment();
+ Assert.equal(packet.type, "paused");
+ const why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ Assert.equal(why.actors[0], breakpointClient.actor);
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, 8);
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value, 1);
+ Assert.equal(variables.c.value.type, "undefined");
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets.js
new file mode 100644
index 0000000000..5700097ea6
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line-with-no-offsets.js
@@ -0,0 +1,44 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-line-with-no-offsets.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScript(SOURCE_URL, debuggee);
+ const { source } = await promise;
+ const sourceFront = threadFront.source(source);
+
+ const location = { line: 5 };
+ let [packet, breakpointClient] = await setBreakpoint(
+ sourceFront,
+ location
+ );
+ Assert.ok(!packet.isPending);
+ Assert.ok("actualLocation" in packet);
+ const actualLocation = packet.actualLocation;
+ Assert.equal(actualLocation.line, 6);
+
+ packet = await executeOnNextTickAndWaitForPause(function () {
+ Cu.evalInSandbox("f()", debuggee);
+ }, threadFront);
+ const environment = await packet.frame.getEnvironment();
+ Assert.equal(packet.type, "paused");
+ const why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ Assert.equal(why.actors[0], breakpointClient.actor);
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, actualLocation.line);
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value, 1);
+ Assert.equal(variables.c.value.type, "undefined");
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_setBreakpoint-on-line.js b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line.js
new file mode 100644
index 0000000000..93e01b757c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_setBreakpoint-on-line.js
@@ -0,0 +1,36 @@
+"use strict";
+
+const SOURCE_URL = getFileUrl("setBreakpoint-on-line.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+ loadSubScript(SOURCE_URL, debuggee);
+ const { source } = await promise;
+ const sourceFront = threadFront.source(source);
+
+ const location = { sourceUrl: sourceFront.url, line: 5 };
+ setBreakpoint(threadFront, location);
+
+ const packet = await executeOnNextTickAndWaitForPause(function () {
+ Cu.evalInSandbox("f()", debuggee);
+ }, threadFront);
+ const environment = await packet.frame.getEnvironment();
+ const why = packet.why;
+ Assert.equal(why.type, "breakpoint");
+ Assert.equal(why.actors.length, 1);
+ const frame = packet.frame;
+ const where = frame.where;
+ Assert.equal(where.actor, source.actor);
+ Assert.equal(where.line, location.line);
+ const variables = environment.bindings.variables;
+ Assert.equal(variables.a.value, 1);
+ Assert.equal(variables.b.value.type, "undefined");
+ Assert.equal(variables.c.value.type, "undefined");
+
+ await resume(threadFront);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_shapes_highlighter_helpers.js b/devtools/server/tests/xpcshell/test_shapes_highlighter_helpers.js
new file mode 100644
index 0000000000..6876f0a532
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_shapes_highlighter_helpers.js
@@ -0,0 +1,274 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Test the helper functions of the shapes highlighter.
+ */
+
+"use strict";
+
+const {
+ splitCoords,
+ coordToPercent,
+ evalCalcExpression,
+ shapeModeToCssPropertyName,
+ getCirclePath,
+ getDecimalPrecision,
+ getUnit,
+} = require("resource://devtools/server/actors/highlighters/shapes.js");
+
+function run_test() {
+ test_split_coords();
+ test_coord_to_percent();
+ test_eval_calc_expression();
+ test_shape_mode_to_css_property_name();
+ test_get_circle_path();
+ test_get_decimal_precision();
+ test_get_unit();
+ run_next_test();
+}
+
+function test_split_coords() {
+ const tests = [
+ {
+ desc: "splitCoords for basic coordinate pair",
+ expr: "30% 20%",
+ expected: ["30%", "20%"],
+ },
+ {
+ desc: "splitCoords for coord pair with calc()",
+ expr: "calc(50px + 20%) 30%",
+ expected: ["calc(50px\u00a0+\u00a020%)", "30%"],
+ },
+ ];
+
+ for (const { desc, expr, expected } of tests) {
+ deepEqual(splitCoords(expr), expected, desc);
+ }
+}
+
+function test_coord_to_percent() {
+ const size = 1000;
+ const tests = [
+ {
+ desc: "coordToPercent for percent value",
+ expr: "50%",
+ expected: 50,
+ },
+ {
+ desc: "coordToPercent for px value",
+ expr: "500px",
+ expected: 50,
+ },
+ {
+ desc: "coordToPercent for zero value",
+ expr: "0",
+ expected: 0,
+ },
+ ];
+
+ for (const { desc, expr, expected } of tests) {
+ equal(coordToPercent(expr, size), expected, desc);
+ }
+}
+
+function test_eval_calc_expression() {
+ const size = 1000;
+ const tests = [
+ {
+ desc: "evalCalcExpression with one value",
+ expr: "50%",
+ expected: 50,
+ },
+ {
+ desc: "evalCalcExpression with percent and px values",
+ expr: "50% + 100px",
+ expected: 60,
+ },
+ {
+ desc: "evalCalcExpression with a zero value",
+ expr: "0 + 100px",
+ expected: 10,
+ },
+ {
+ desc: "evalCalcExpression with a negative value",
+ expr: "-200px+50%",
+ expected: 30,
+ },
+ ];
+
+ for (const { desc, expr, expected } of tests) {
+ equal(evalCalcExpression(expr, size), expected, desc);
+ }
+}
+
+function test_shape_mode_to_css_property_name() {
+ const tests = [
+ {
+ desc: "shapeModeToCssPropertyName for clip-path",
+ expr: "cssClipPath",
+ expected: "clipPath",
+ },
+ {
+ desc: "shapeModeToCssPropertyName for shape-outside",
+ expr: "cssShapeOutside",
+ expected: "shapeOutside",
+ },
+ ];
+
+ for (const { desc, expr, expected } of tests) {
+ equal(shapeModeToCssPropertyName(expr), expected, desc);
+ }
+}
+
+function test_get_circle_path() {
+ const tests = [
+ {
+ desc: "getCirclePath with size 5, no resizing, no zoom, 1:1 ratio",
+ size: 5,
+ cx: 0,
+ cy: 0,
+ width: 100,
+ height: 100,
+ zoom: 1,
+ expected: "M-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0",
+ },
+ {
+ desc: "getCirclePath with size 7, resizing, no zoom, 1:1 ratio",
+ size: 7,
+ cx: 0,
+ cy: 0,
+ width: 200,
+ height: 200,
+ zoom: 1,
+ expected: "M-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0",
+ },
+ {
+ desc: "getCirclePath with size 5, resizing, zoom, 1:1 ratio",
+ size: 5,
+ cx: 0,
+ cy: 0,
+ width: 200,
+ height: 200,
+ zoom: 2,
+ expected: "M-1.25,0a1.25,1.25 0 1,0 2.5,0a1.25,1.25 0 1,0 -2.5,0",
+ },
+ {
+ desc: "getCirclePath with size 5, resizing, zoom, non-square ratio",
+ size: 5,
+ cx: 0,
+ cy: 0,
+ width: 100,
+ height: 200,
+ zoom: 2,
+ expected: "M-2.5,0a2.5,1.25 0 1,0 5,0a2.5,1.25 0 1,0 -5,0",
+ },
+ ];
+
+ for (const { desc, size, cx, cy, width, height, zoom, expected } of tests) {
+ equal(getCirclePath(size, cx, cy, width, height, zoom), expected, desc);
+ }
+}
+
+function test_get_decimal_precision() {
+ const tests = [
+ {
+ desc: "getDecimalPrecision with px",
+ expr: "px",
+ expected: 0,
+ },
+ {
+ desc: "getDecimalPrecision with %",
+ expr: "%",
+ expected: 2,
+ },
+ {
+ desc: "getDecimalPrecision with em",
+ expr: "em",
+ expected: 2,
+ },
+ {
+ desc: "getDecimalPrecision with undefined",
+ expr: undefined,
+ expected: 0,
+ },
+ {
+ desc: "getDecimalPrecision with empty string",
+ expr: "",
+ expected: 0,
+ },
+ ];
+
+ for (const { desc, expr, expected } of tests) {
+ equal(getDecimalPrecision(expr), expected, desc);
+ }
+}
+
+function test_get_unit() {
+ const tests = [
+ {
+ desc: "getUnit with %",
+ expr: "30%",
+ expected: "%",
+ },
+ {
+ desc: "getUnit with px",
+ expr: "400px",
+ expected: "px",
+ },
+ {
+ desc: "getUnit with em",
+ expr: "4em",
+ expected: "em",
+ },
+ {
+ desc: "getUnit with 0",
+ expr: "0",
+ expected: "px",
+ },
+ {
+ desc: "getUnit with 0%",
+ expr: "0%",
+ expected: "%",
+ },
+ {
+ desc: "getUnit with 0.00%",
+ expr: "0.00%",
+ expected: "%",
+ },
+ {
+ desc: "getUnit with 0px",
+ expr: "0px",
+ expected: "px",
+ },
+ {
+ desc: "getUnit with 0em",
+ expr: "0em",
+ expected: "em",
+ },
+ {
+ desc: "getUnit with calc",
+ expr: "calc(30px + 5%)",
+ expected: "px",
+ },
+ {
+ desc: "getUnit with var",
+ expr: "var(--variable)",
+ expected: "px",
+ },
+ {
+ desc: "getUnit with closest-side",
+ expr: "closest-side",
+ expected: "px",
+ },
+ {
+ desc: "getUnit with farthest-side",
+ expr: "farthest-side",
+ expected: "px",
+ },
+ ];
+
+ for (const { desc, expr, expected } of tests) {
+ equal(getUnit(expr), expected, desc);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/test_source-01.js b/devtools/server/tests/xpcshell/test_source-01.js
new file mode 100644
index 0000000000..5cb7a6da52
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_source-01.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test ensures that we can create SourceActors and SourceFronts properly,
+// and that they can communicate over the protocol to fetch the source text for
+// a given script.
+
+const SOURCE_URL = "http://example.com/foobar.js";
+const SOURCE_CONTENT = "stopMe()";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ DevToolsServer.LONG_STRING_LENGTH = 200;
+
+ await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ const response = await threadFront.getSources();
+
+ Assert.ok(!!response);
+ Assert.ok(!!response.sources);
+
+ const source = response.sources.filter(function (s) {
+ return s.url === SOURCE_URL;
+ })[0];
+
+ Assert.ok(!!source);
+
+ const sourceFront = threadFront.source(source);
+ const response2 = await sourceFront.source();
+
+ Assert.ok(!!response2);
+ Assert.ok(!!response2.contentType);
+ Assert.ok(response2.contentType.includes("javascript"));
+
+ Assert.ok(!!response2.source);
+ Assert.equal(SOURCE_CONTENT, response2.source);
+
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ Cu.evalInSandbox(
+ "" +
+ function stopMe(arg1) {
+ debugger;
+ },
+ debuggee,
+ "1.8",
+ getFileUrl("test_source-01.js")
+ );
+
+ Cu.evalInSandbox(SOURCE_CONTENT, debuggee, "1.8", SOURCE_URL);
+}
diff --git a/devtools/server/tests/xpcshell/test_source-02.js b/devtools/server/tests/xpcshell/test_source-02.js
new file mode 100644
index 0000000000..9cb88cb0e4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_source-02.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test ensures that we can create SourceActors and SourceFronts properly,
+// and that they can communicate over the protocol to fetch the source text for
+// a given script.
+
+const SOURCE_URL = "http://example.com/foobar.js";
+const SOURCE_CONTENT = `
+ stopMe();
+ for(var i = 0; i < 2; i++) {
+ debugger;
+ }
+`;
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ DevToolsServer.LONG_STRING_LENGTH = 200;
+
+ await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ let response = await threadFront.getSources();
+ Assert.ok(!!response);
+ Assert.ok(!!response.sources);
+
+ const source = response.sources.filter(function (s) {
+ return s.url === SOURCE_URL;
+ })[0];
+
+ Assert.ok(!!source);
+
+ const sourceFront = threadFront.source(source);
+ response = await sourceFront.getBreakpointPositionsCompressed();
+ Assert.ok(!!response);
+
+ Assert.deepEqual(response, {
+ 2: [2],
+ 3: [14, 17, 24],
+ 4: [4],
+ 6: [0],
+ });
+
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ Cu.evalInSandbox(
+ "" +
+ function stopMe(arg1) {
+ debugger;
+ },
+ debuggee,
+ "1.8",
+ getFileUrl("test_source-02.js")
+ );
+
+ Cu.evalInSandbox(SOURCE_CONTENT, debuggee, "1.8", SOURCE_URL);
+}
diff --git a/devtools/server/tests/xpcshell/test_source-03.js b/devtools/server/tests/xpcshell/test_source-03.js
new file mode 100644
index 0000000000..d0cd4839a0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_source-03.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SOURCE_URL = getFileUrl("source-03.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, server }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+
+ // Create a two globals in the default junk sandbox compartment so that
+ // both globals are part of the same compartment.
+ server.allowNewThreadGlobals();
+ const debuggee1 = Cu.Sandbox(systemPrincipal);
+ debuggee1.__name = "debuggee2.js";
+ const debuggee2 = Cu.Sandbox(systemPrincipal);
+ debuggee2.__name = "debuggee2.js";
+ server.disallowNewThreadGlobals();
+
+ // Load two copies of the source file. The first call to "loadSubScript" will
+ // create a ScriptSourceObject and a JSScript which references it.
+ // The second call will attempt to re-use JSScript objects because that is
+ // what loadSubScript does for instances of the same file that are loaded
+ // in the system principal in the same compartment.
+ //
+ // We explicitly want this because it is an edge case of the server. Most
+ // of the time a Debugger.Source will only have a single Debugger.Script
+ // associated with a given function, but in the context of explicitly
+ // cloned JSScripts, this is not the case, and we need to handle that.
+ loadSubScript(SOURCE_URL, debuggee1);
+ loadSubScript(SOURCE_URL, debuggee2);
+
+ await promise;
+
+ // We want to set a breakpoint and make sure that the breakpoint is properly
+ // set on _both_ files backed
+ await setBreakpoint(threadFront, {
+ sourceUrl: SOURCE_URL,
+ line: 4,
+ });
+
+ const { sources } = await getSources(threadFront);
+
+ // Note: Since we load the file twice, we end up with two copies of the
+ // source object, and so two sources here.
+ Assert.equal(sources.length, 2);
+
+ // Ensure that the breakpoint was properly applied to the JSScipt loaded
+ // in the first global.
+ let pausedOne = false;
+ let onResumed = null;
+ threadFront.once("paused", function (packet) {
+ pausedOne = true;
+ onResumed = resume(threadFront);
+ });
+ Cu.evalInSandbox("init()", debuggee1, "1.8", "test.js", 1);
+ await onResumed;
+ Assert.equal(pausedOne, true);
+
+ // Ensure that the breakpoint was properly applied to the JSScipt loaded
+ // in the second global.
+ let pausedTwo = false;
+ threadFront.once("paused", function (packet) {
+ pausedTwo = true;
+ onResumed = resume(threadFront);
+ });
+ Cu.evalInSandbox("init()", debuggee2, "1.8", "test.js", 1);
+ await onResumed;
+ Assert.equal(pausedTwo, true);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_source-04.js b/devtools/server/tests/xpcshell/test_source-04.js
new file mode 100644
index 0000000000..a3e3bef25f
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_source-04.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SOURCE_URL = getFileUrl("source-03.js");
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, server }) => {
+ const promise = waitForNewSource(threadFront, SOURCE_URL);
+
+ // Create two globals in the default junk sandbox compartment so that
+ // both globals are part of the same compartment.
+ server.allowNewThreadGlobals();
+ const debuggee1 = Cu.Sandbox(systemPrincipal);
+ debuggee1.__name = "debuggee2.js";
+ const debuggee2 = Cu.Sandbox(systemPrincipal);
+ debuggee2.__name = "debuggee2.js";
+ server.disallowNewThreadGlobals();
+
+ // Load first copy of the source file. The first call to "loadSubScript" will
+ // create a ScriptSourceObject and a JSScript which references it.
+ loadSubScript(SOURCE_URL, debuggee1);
+
+ await promise;
+
+ // We want to set a breakpoint and make sure that the breakpoint is properly
+ // set on _both_ files backed
+ await setBreakpoint(threadFront, {
+ sourceUrl: SOURCE_URL,
+ line: 4,
+ });
+
+ const { sources } = await getSources(threadFront);
+ Assert.equal(sources.length, 1);
+
+ // Ensure that the breakpoint was properly applied to the JSScipt loaded
+ // in the first global.
+ let pausedOne = false;
+ let onResumed = null;
+ threadFront.once("paused", function (packet) {
+ pausedOne = true;
+ onResumed = resume(threadFront);
+ });
+ Cu.evalInSandbox("init()", debuggee1, "1.8", "test.js", 1);
+ await onResumed;
+ Assert.equal(pausedOne, true);
+
+ // Load second copy of the source file. The second call will attempt to
+ // re-use JSScript objects because that is what loadSubScript does for
+ // instances of the same file that are loaded in the system principal in
+ // the same compartment.
+ //
+ // We explicitly want this because it is an edge case of the server. Most
+ // of the time a Debugger.Source will only have a single Debugger.Script
+ // associated with a given function, but in the context of explicitly
+ // cloned JSScripts, this is not the case, and we need to handle that.
+ loadSubScript(SOURCE_URL, debuggee2);
+
+ // Ensure that the breakpoint was properly applied to the JSScipt loaded
+ // in the second global.
+ let pausedTwo = false;
+ threadFront.once("paused", function (packet) {
+ pausedTwo = true;
+ onResumed = resume(threadFront);
+ });
+ Cu.evalInSandbox("init()", debuggee2, "1.8", "test.js", 1);
+ await onResumed;
+ Assert.equal(pausedTwo, true);
+ },
+ { doNotRunWorker: true }
+ )
+);
diff --git a/devtools/server/tests/xpcshell/test_stepping-01.js b/devtools/server/tests/xpcshell/test_stepping-01.js
new file mode 100644
index 0000000000..0c66404510
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-01.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check scenarios where we're leaving function a and
+ * going to the function b's call-site.
+ */
+
+async function testFinish({ threadFront, devToolsClient }) {
+ await close(devToolsClient);
+
+ do_test_finished();
+}
+
+async function invokeAndPause({ global, threadFront }, expression) {
+ return executeOnNextTickAndWaitForPause(
+ () => Cu.evalInSandbox(expression, global),
+ threadFront
+ );
+}
+
+async function step(threadFront, cmd) {
+ return cmd(threadFront);
+}
+
+function getPauseLocation(packet) {
+ const { line, column } = packet.frame.where;
+ return { line, column };
+}
+
+function getPauseReturn(packet) {
+ return packet.why.frameFinished.return;
+}
+
+async function steps(threadFront, sequence) {
+ const locations = [];
+ for (const cmd of sequence) {
+ const packet = await step(threadFront, cmd);
+ locations.push(getPauseLocation(packet));
+ }
+ return locations;
+}
+
+async function stepOutOfA(dbg, func, expectedLocation) {
+ await invokeAndPause(dbg, `${func}()`);
+ const { threadFront } = dbg;
+ await steps(threadFront, [stepOver, stepIn]);
+
+ const packet = await stepOut(threadFront);
+
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ `step out location in ${func}`
+ );
+
+ await threadFront.resume();
+}
+
+async function stepOverInA(dbg, func, expectedLocation) {
+ await invokeAndPause(dbg, `${func}()`);
+ const { threadFront } = dbg;
+ await steps(threadFront, [stepOver, stepIn]);
+
+ let packet = await stepOver(threadFront);
+ equal(getPauseReturn(packet).ownPropertyLength, 1, "a() is returning obj");
+
+ packet = await stepOver(threadFront);
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ `step out location in ${func}`
+ );
+ await dbg.threadFront.resume();
+}
+
+async function testStep(dbg, func, expectedValue) {
+ await stepOverInA(dbg, func, expectedValue);
+ await stepOutOfA(dbg, func, expectedValue);
+}
+
+function run_test() {
+ return (async function () {
+ const dbg = await setupTestFromUrl("stepping.js");
+
+ await testStep(dbg, "arithmetic", { line: 16, column: 8 });
+ await testStep(dbg, "composition", { line: 21, column: 3 });
+ await testStep(dbg, "chaining", { line: 26, column: 6 });
+
+ await testFinish(dbg);
+ })();
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-02.js b/devtools/server/tests/xpcshell/test_stepping-02.js
new file mode 100644
index 0000000000..c9df671839
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-02.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check basic step-in functionality.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+ const dbgStmt = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ equal(
+ dbgStmt.frame.where.line,
+ 2,
+ "Should be at debugger statement on line 2"
+ );
+ equal(debuggee.a, undefined);
+ equal(debuggee.b, undefined);
+
+ const step1 = await stepIn(threadFront);
+ equal(step1.why.type, "resumeLimit");
+ equal(step1.frame.where.line, 3);
+ equal(debuggee.a, undefined);
+ equal(debuggee.b, undefined);
+
+ const step3 = await stepIn(threadFront);
+ equal(step3.why.type, "resumeLimit");
+ equal(step3.frame.where.line, 4);
+ equal(debuggee.a, 1);
+ equal(debuggee.b, undefined);
+
+ const step4 = await stepIn(threadFront);
+ equal(step4.why.type, "resumeLimit");
+ equal(step4.frame.where.line, 4);
+ equal(debuggee.a, 1);
+ equal(debuggee.b, 2);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ ` // 1
+ debugger; // 2
+ var a = 1; // 3
+ var b = 2;`, // 4
+ debuggee,
+ "1.8",
+ "test_stepping-01-test-code.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-03.js b/devtools/server/tests/xpcshell/test_stepping-03.js
new file mode 100644
index 0000000000..88422ac0cc
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-03.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check basic step-out functionality.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+ await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const step1 = await stepOut(threadFront);
+ equal(step1.frame.where.line, 8);
+ equal(step1.why.type, "resumeLimit");
+
+ equal(debuggee.a, 1);
+ equal(debuggee.b, 2);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ ` // 1
+ function f() { // 2
+ debugger; // 3
+ this.a = 1; // 4
+ this.b = 2; // 5
+ } // 6
+ f(); // 7
+ `, // 8
+ debuggee,
+ "1.8",
+ "test_stepping-01-test-code.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-04.js b/devtools/server/tests/xpcshell/test_stepping-04.js
new file mode 100644
index 0000000000..37a9f843d0
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-04.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that stepping over a function call does not pause inside the function.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+ await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ dumpn("Step Over to f()");
+ const step1 = await stepOver(threadFront);
+ equal(step1.why.type, "resumeLimit");
+ equal(step1.frame.where.line, 6);
+ equal(debuggee.a, undefined);
+ equal(debuggee.b, undefined);
+
+ dumpn("Step Over f()");
+ const step2 = await stepOver(threadFront);
+ equal(step2.frame.where.line, 7);
+ equal(step2.why.type, "resumeLimit");
+ equal(debuggee.a, 1);
+ equal(debuggee.b, undefined);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ ` // 1
+ function f() { // 2
+ this.a = 1; // 3
+ } // 4
+ debugger; // 5
+ f(); // 6
+ let b = 2; // 7
+ `, // 8
+ debuggee,
+ "1.8",
+ "test_stepping-01-test-code.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-05.js b/devtools/server/tests/xpcshell/test_stepping-05.js
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-05.js
diff --git a/devtools/server/tests/xpcshell/test_stepping-06.js b/devtools/server/tests/xpcshell/test_stepping-06.js
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-06.js
diff --git a/devtools/server/tests/xpcshell/test_stepping-07.js b/devtools/server/tests/xpcshell/test_stepping-07.js
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-07.js
diff --git a/devtools/server/tests/xpcshell/test_stepping-08.js b/devtools/server/tests/xpcshell/test_stepping-08.js
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-08.js
diff --git a/devtools/server/tests/xpcshell/test_stepping-09.js b/devtools/server/tests/xpcshell/test_stepping-09.js
new file mode 100644
index 0000000000..da59ed963c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-09.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that step out stops at the end of the parent if it fails to stop
+ * anywhere else. Bug 1504358.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+ const dbgStmt = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ equal(
+ dbgStmt.frame.where.line,
+ 2,
+ "Should be at debugger statement on line 2"
+ );
+
+ dumpn("Step out of inner and into outer");
+ const step2 = await stepOut(threadFront);
+ // The bug was that we'd step right past the end of the function and never pause.
+ equal(step2.frame.where.line, 2);
+ equal(step2.frame.where.column, 31);
+ deepEqual(step2.why.frameFinished.return, { type: "undefined" });
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // By placing the inner and outer on the same line, this triggers the server's
+ // logic to skip steps for these functions, meaning that onPop is the only
+ // thing that will cause it to pop.
+ Cu.evalInSandbox(
+ `
+ function outer(){ inner(); return 42; } function inner(){ debugger; }
+ outer();
+ `,
+ debuggee,
+ "1.8",
+ "test_stepping-09-test-code.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-10.js b/devtools/server/tests/xpcshell/test_stepping-10.js
new file mode 100644
index 0000000000..6ea95c3fd3
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-10.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that step out stops at the parent and the parent's parent.
+ * This checks for the failure found in bug 1530549.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+ const dbgStmt = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ equal(
+ dbgStmt.frame.where.line,
+ 3,
+ "Should be at debugger statement on line 3"
+ );
+
+ dumpn("Step out of inner and into var statement IIFE");
+ const step2 = await stepOut(threadFront);
+ equal(step2.frame.where.line, 4);
+ deepEqual(step2.why.frameFinished.return, { type: "undefined" });
+
+ dumpn("Step out of vars and into script body");
+ const step3 = await stepOut(threadFront);
+ equal(step3.frame.where.line, 9);
+ deepEqual(step3.why.frameFinished.return, { type: "undefined" });
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ Cu.evalInSandbox(
+ `
+ (function() {
+ (function(){debugger;})();
+ var a = 1;
+ a = 2;
+ a = 3;
+ a = 4;
+ })();
+ `,
+ debuggee,
+ "1.8",
+ "test_stepping-10-test-code.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-11.js b/devtools/server/tests/xpcshell/test_stepping-11.js
new file mode 100644
index 0000000000..8cbd285d89
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-11.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check basic stepping for console evaluations.
+ */
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+
+ commands.scriptCommand.execute(`(function(){
+ debugger;
+ var a = 1;
+ var b = 2;
+ })();`);
+
+ await waitForEvent(threadFront, "paused");
+ const packet = await stepOver(threadFront);
+ Assert.equal(packet.frame.where.line, 3, "step to line 3");
+ await threadFront.resume();
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_stepping-12.js b/devtools/server/tests/xpcshell/test_stepping-12.js
new file mode 100644
index 0000000000..de96faf59f
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-12.js
@@ -0,0 +1,162 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that step out stops at the parent and the parent's parent.
+ * This checks for the failure found in bug 1530549.
+ */
+
+const sourceUrl = "test_stepping-10-test-code.js";
+
+add_task(
+ threadFrontTest(async args => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+
+ await testGenerator(args);
+ await testAwait(args);
+ await testInterleaving(args);
+ await testMultipleSteps(args);
+ })
+);
+
+async function testAwait({ threadFront, debuggee }) {
+ function evaluateTestCode() {
+ Cu.evalInSandbox(
+ `
+ (async function() {
+ debugger;
+ r = await Promise.resolve('yay');
+ a = 4;
+ })();
+ `,
+ debuggee,
+ "1.8",
+ sourceUrl,
+ 1
+ );
+ }
+
+ await executeOnNextTickAndWaitForPause(evaluateTestCode, threadFront);
+
+ dumpn("Step Over and land on line 5");
+ const step1 = await stepOver(threadFront);
+ equal(step1.frame.where.line, 4);
+ equal(step1.frame.where.column, 10);
+
+ const step2 = await stepOver(threadFront);
+ equal(step2.frame.where.line, 5);
+ equal(step2.frame.where.column, 10);
+ equal(debuggee.r, "yay");
+ await resume(threadFront);
+}
+
+async function testInterleaving({ threadFront, debuggee }) {
+ function evaluateTestCode() {
+ Cu.evalInSandbox(
+ `
+ (async function simpleRace() {
+ debugger;
+ this.result = await new Promise((r) => {
+ Promise.resolve().then(() => { debugger });
+ Promise.resolve().then(r('yay'))
+ })
+ var a = 2;
+ debugger;
+ })()
+ `,
+ debuggee,
+ "1.8",
+ sourceUrl,
+ 1
+ );
+ }
+
+ await executeOnNextTickAndWaitForPause(evaluateTestCode, threadFront);
+
+ dumpn("Step Over and land on line 5");
+ const step1 = await stepOver(threadFront);
+ equal(step1.frame.where.line, 4);
+
+ const step2 = await stepOver(threadFront);
+ equal(step2.frame.where.line, 5);
+ equal(step2.frame.where.column, 43);
+
+ const step3 = await resumeAndWaitForPause(threadFront);
+ equal(step3.frame.where.line, 9);
+ equal(debuggee.result, "yay");
+
+ await resume(threadFront);
+}
+
+async function testMultipleSteps({ threadFront, debuggee }) {
+ function evaluateTestCode() {
+ Cu.evalInSandbox(
+ `
+ (async function simpleRace() {
+ debugger;
+ await Promise.resolve();
+ var a = 2;
+ await Promise.resolve();
+ var b = 2;
+ await Promise.resolve();
+ debugger;
+ })()
+ `,
+ debuggee,
+ "1.8",
+ sourceUrl,
+ 1
+ );
+ }
+
+ await executeOnNextTickAndWaitForPause(evaluateTestCode, threadFront);
+
+ const step1 = await stepOver(threadFront);
+ equal(step1.frame.where.line, 4);
+
+ const step2 = await stepOver(threadFront);
+ equal(step2.frame.where.line, 5);
+
+ const step3 = await stepOver(threadFront);
+ equal(step3.frame.where.line, 6);
+ resume(threadFront);
+}
+
+async function testGenerator({ threadFront, debuggee }) {
+ function evaluateTestCode() {
+ Cu.evalInSandbox(
+ `
+ (async function() {
+ function* makeSteps() {
+ debugger;
+ yield 1;
+ yield 2;
+ return 3;
+ }
+ const s = makeSteps();
+ s.next();
+ s.next();
+ s.next();
+ })()
+ `,
+ debuggee,
+ "1.8",
+ sourceUrl,
+ 1
+ );
+ }
+
+ await executeOnNextTickAndWaitForPause(evaluateTestCode, threadFront);
+
+ const step1 = await stepOver(threadFront);
+ equal(step1.frame.where.line, 5);
+
+ const step2 = await stepOver(threadFront);
+ equal(step2.frame.where.line, 6);
+
+ const step3 = await stepOver(threadFront);
+ equal(step3.frame.where.line, 7);
+ await resume(threadFront);
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-13.js b/devtools/server/tests/xpcshell/test_stepping-13.js
new file mode 100644
index 0000000000..cbdb78ce2d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-13.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Check that is possible to step into both the inner and outer function
+ * calls.
+ */
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+
+ commands.scriptCommand.execute(
+ `(function () {
+ const a = () => { return 2 };
+ debugger;
+ a(a())
+ })()`
+ );
+
+ await waitForEvent(threadFront, "paused");
+ const step1 = await stepOver(threadFront);
+ Assert.equal(step1.frame.where.line, 4, "step to line 4");
+
+ const step2 = await stepIn(threadFront);
+ Assert.equal(step2.frame.where.line, 2, "step in to line 2");
+
+ const step3 = await stepOut(threadFront);
+ Assert.equal(step3.frame.where.line, 4, "step back to line 4");
+ Assert.equal(step3.frame.where.column, 9, "step out to column 9");
+
+ const step4 = await stepIn(threadFront);
+ Assert.equal(step4.frame.where.line, 2, "step in to line 2");
+
+ await threadFront.resume();
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_stepping-14.js b/devtools/server/tests/xpcshell/test_stepping-14.js
new file mode 100644
index 0000000000..6d64a53a66
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-14.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Check that is possible to step into both the inner and outer function
+ * calls.
+ */
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+
+ commands.scriptCommand.execute(`(function () {
+ async function f() {
+ const p = Promise.resolve(43);
+ await p;
+ return p;
+ }
+
+ function call_f() {
+ Promise.resolve(42).then(forty_two => {
+ return forty_two;
+ });
+
+ f().then(v => {
+ return v;
+ });
+ }
+ debugger;
+ call_f();
+ })()`);
+
+ const packet = await waitForEvent(threadFront, "paused");
+ const location = {
+ sourceId: packet.frame.where.actor,
+ line: 4,
+ column: 10,
+ };
+
+ await threadFront.setBreakpoint(location, {});
+
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4, "landed at await");
+
+ const packet3 = await stepIn(threadFront);
+ Assert.equal(packet3.frame.where.line, 5, "step to the next line");
+
+ await threadFront.resume();
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_stepping-15.js b/devtools/server/tests/xpcshell/test_stepping-15.js
new file mode 100644
index 0000000000..9e79b93687
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-15.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test stepping from inside a blackboxed function
+ * test-page: https://dbg-blackbox-stepping.glitch.me/
+ */
+
+async function invokeAndPause({ global, threadFront }, expression, url) {
+ return executeOnNextTickAndWaitForPause(
+ () => Cu.evalInSandbox(expression, global, "1.8", url, 1),
+ threadFront
+ );
+}
+add_task(
+ threadFrontTest(async ({ commands, threadFront, debuggee }) => {
+ const dbg = { global: debuggee, threadFront };
+
+ // Test stepping from a blackboxed location
+ async function testStepping(action, expectedLine) {
+ commands.scriptCommand.execute(`outermost()`);
+ await waitForPause(threadFront);
+ await blackBox(blackboxedSourceFront);
+ const packet = await action(threadFront);
+ const { line, actor } = packet.frame.where;
+ equal(actor, unblackboxedActor, "paused in unblackboxed source");
+ equal(line, expectedLine, "paused at correct line");
+ await threadFront.resume();
+ await unBlackBox(blackboxedSourceFront);
+ }
+
+ invokeAndPause(
+ dbg,
+ `function outermost() {
+ const value = blackboxed1();
+ return value + 1;
+ }
+ function innermost() {
+ return 1;
+ }`,
+ "http://example.com/unblackboxed.js"
+ );
+ invokeAndPause(
+ dbg,
+ `function blackboxed1() {
+ return blackboxed2();
+ }
+ function blackboxed2() {
+ return innermost();
+ }`,
+ "http://example.com/blackboxed.js"
+ );
+
+ const { sources } = await getSources(threadFront);
+ const blackboxedSourceFront = threadFront.source(
+ sources.find(source => source.url == "http://example.com/blackboxed.js")
+ );
+ const unblackboxedActor = sources.find(
+ source => source.url == "http://example.com/unblackboxed.js"
+ ).actor;
+
+ await setBreakpoint(threadFront, {
+ sourceUrl: blackboxedSourceFront.url,
+ line: 5,
+ });
+
+ info("Step Out to outermost");
+ await testStepping(stepOut, 3);
+
+ info("Step Over to outermost");
+ await testStepping(stepOver, 3);
+
+ info("Step In to innermost");
+ await testStepping(stepIn, 6);
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_stepping-16.js b/devtools/server/tests/xpcshell/test_stepping-16.js
new file mode 100644
index 0000000000..e3bd94b747
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-16.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test stepping from inside a blackboxed function
+ * test-page: https://dbg-blackbox-stepping2.glitch.me/
+ */
+
+async function invokeAndPause({ global, threadFront }, expression, url) {
+ return executeOnNextTickAndWaitForPause(
+ () => Cu.evalInSandbox(expression, global, "1.8", url, 1),
+ threadFront
+ );
+}
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront, debuggee }) => {
+ const dbg = { global: debuggee, threadFront };
+ invokeAndPause(
+ dbg,
+ `function outermost() {
+ blackboxed(
+ function inner1() {
+ return 1;
+ },
+ function inner2() {
+ return 2;
+ }
+ );
+ }`,
+ "http://example.com/unblackboxed.js"
+ );
+ invokeAndPause(
+ dbg,
+ `function blackboxed(...args) {
+ for (const arg of args) {
+ arg();
+ }
+ }`,
+ "http://example.com/blackboxed.js"
+ );
+
+ const { sources } = await getSources(threadFront);
+ const blackboxedSourceFront = threadFront.source(
+ sources.find(source => source.url == "http://example.com/blackboxed.js")
+ );
+ const unblackboxedSource = sources.find(
+ source => source.url == "http://example.com/unblackboxed.js"
+ );
+ const unblackboxedActor = unblackboxedSource.actor;
+ const unblackboxedSourceFront = threadFront.source(unblackboxedSource);
+
+ await setBreakpoint(threadFront, {
+ sourceUrl: unblackboxedSourceFront.url,
+ line: 4,
+ });
+ blackBox(blackboxedSourceFront);
+
+ async function testStepping(action, expectedLine) {
+ commands.scriptCommand.execute("outermost()");
+ await waitForPause(threadFront);
+ await stepOver(threadFront);
+ const packet = await action(threadFront);
+ const { actor, line } = packet.frame.where;
+ equal(actor, unblackboxedActor, "Paused in unblackboxed source");
+ equal(line, expectedLine, "Paused at correct line");
+ await threadFront.resume();
+ }
+
+ info("Step Out to outermost");
+ await testStepping(stepOut, 10);
+
+ info("Step Over to outermost");
+ await testStepping(stepOver, 10);
+
+ info("Step In to inner2");
+ await testStepping(stepIn, 7);
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_stepping-17.js b/devtools/server/tests/xpcshell/test_stepping-17.js
new file mode 100644
index 0000000000..816946fa4c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-17.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Check that you can step from one script or event to another
+ */
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront, debuggee }) => {
+ Cu.evalInSandbox(
+ `function blackboxed(callback) { return () => callback(); }`,
+ debuggee,
+ "1.8",
+ "http://example.com/blackboxed.js",
+ 1
+ );
+
+ const { sources } = await getSources(threadFront);
+ const blackboxedSourceFront = threadFront.source(
+ sources.find(source => source.url == "http://example.com/blackboxed.js")
+ );
+ blackBox(blackboxedSourceFront);
+
+ const testStepping = async function (wrapperName, stepHandler, message) {
+ commands.scriptCommand.execute(`(function () {
+ const p = Promise.resolve();
+ p.then(${wrapperName}(() => { debugger; }))
+ .then(${wrapperName}(() => { }));
+ })();`);
+
+ await waitForEvent(threadFront, "paused");
+ const step = await stepHandler(threadFront);
+ Assert.equal(step.frame.where.line, 4, message);
+ await resume(threadFront);
+ };
+
+ const stepTwice = async function () {
+ await stepOver(threadFront);
+ return stepOver(threadFront);
+ };
+
+ await testStepping("", stepTwice, "Step over on the outermost frame");
+ await testStepping("blackboxed", stepTwice, "Step over with blackboxing");
+ await testStepping("", stepOut, "Step out on the outermost frame");
+ await testStepping("blackboxed", stepOut, "Step out with blackboxing");
+
+ commands.scriptCommand.execute(`(async function () {
+ const p = Promise.resolve();
+ const p2 = p.then(() => {
+ debugger;
+ return "async stepping!";
+ });
+ debugger;
+ await p;
+ const result = await p2;
+ return result;
+ })();
+ `);
+
+ await waitForEvent(threadFront, "paused");
+ await stepOver(threadFront);
+ await stepOver(threadFront);
+ const step = await stepOut(threadFront);
+ await resume(threadFront);
+ Assert.equal(step.frame.where.line, 9, "Step out of promise into async fn");
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_stepping-18.js b/devtools/server/tests/xpcshell/test_stepping-18.js
new file mode 100644
index 0000000000..e8581835d3
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-18.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check scenarios where we're leaving function a and
+ * going to the function b's call-site.
+ */
+
+async function testFinish({ threadFront, devToolsClient }) {
+ await close(devToolsClient);
+
+ do_test_finished();
+}
+
+async function invokeAndPause({ global, threadFront }, expression) {
+ return executeOnNextTickAndWaitForPause(
+ () => Cu.evalInSandbox(expression, global),
+ threadFront
+ );
+}
+
+async function steps(threadFront, sequence) {
+ const locations = [];
+ for (const cmd of sequence) {
+ const packet = await step(threadFront, cmd);
+ locations.push(getPauseLocation(packet));
+ }
+ return locations;
+}
+
+async function step(threadFront, cmd) {
+ return cmd(threadFront);
+}
+
+function getPauseLocation(packet) {
+ const { line, column } = packet.frame.where;
+ return { line, column };
+}
+
+async function stepOutOfA(dbg, func, frameIndex, expectedLocation) {
+ const { threadFront } = dbg;
+
+ info("pause and step into a()");
+ await invokeAndPause(dbg, `${func}()`);
+ await steps(threadFront, [stepOver, stepIn, stepIn]);
+
+ const { frames } = await threadFront.frames(0, 5);
+ const frameActorID = frames[frameIndex].actorID;
+ const packet = await stepOut(threadFront, frameActorID);
+
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ `step over location in ${func}`
+ );
+
+ await dbg.threadFront.resume();
+}
+
+async function stepOverInA(dbg, func, frameIndex, expectedLocation) {
+ const { threadFront } = dbg;
+
+ info("pause and step into a()");
+ await invokeAndPause(dbg, `${func}()`);
+ await steps(threadFront, [stepOver, stepIn]);
+
+ const { frames } = await threadFront.frames(0, 5);
+ const frameActorID = frames[frameIndex].actorID;
+ const packet = await stepOver(threadFront, frameActorID);
+
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ `step over location in ${func}`
+ );
+
+ await dbg.threadFront.resume();
+}
+
+function run_test() {
+ return (async function () {
+ const dbg = await setupTestFromUrl("stepping.js");
+
+ info(`Test step over with the 1st frame`);
+ await stepOverInA(dbg, "arithmetic", 0, { line: 8, column: 0 });
+
+ info(`Test step over with the 2nd frame`);
+ await stepOverInA(dbg, "arithmetic", 1, { line: 17, column: 0 });
+
+ info(`Test step out with the 1st frame`);
+ await stepOutOfA(dbg, "nested", 0, { line: 31, column: 0 });
+
+ info(`Test step out with the 2nd frame`);
+ await stepOutOfA(dbg, "nested", 1, { line: 36, column: 0 });
+
+ await testFinish(dbg);
+ })();
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-19.js b/devtools/server/tests/xpcshell/test_stepping-19.js
new file mode 100644
index 0000000000..7ab21c7b66
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-19.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that step out stops at the async parent's frame.
+ */
+
+async function testFinish({ threadFront, devToolsClient }) {
+ await close(devToolsClient);
+
+ do_test_finished();
+}
+
+async function invokeAndPause({ global, threadFront }, expression) {
+ return executeOnNextTickAndWaitForPause(
+ () => Cu.evalInSandbox(expression, global),
+ threadFront
+ );
+}
+
+async function steps(threadFront, sequence) {
+ const locations = [];
+ for (const cmd of sequence) {
+ const packet = await step(threadFront, cmd);
+ locations.push(getPauseLocation(packet));
+ }
+ return locations;
+}
+
+async function step(threadFront, cmd) {
+ return cmd(threadFront);
+}
+
+function getPauseLocation(packet) {
+ const { line, column } = packet.frame.where;
+ return { line, column };
+}
+
+async function stepOutBeforeTimer(dbg, func, frameIndex, expectedLocation) {
+ const { threadFront } = dbg;
+
+ await invokeAndPause(dbg, `${func}()`);
+ await steps(threadFront, [stepOver, stepIn]);
+
+ const { frames } = await threadFront.frames(0, 5);
+ const frameActorID = frames[frameIndex].actorID;
+ const packet = await stepOut(threadFront, frameActorID);
+
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ `step out location in ${func}`
+ );
+
+ await resumeAndWaitForPause(threadFront);
+ await resume(threadFront);
+}
+
+async function stepOutAfterTimer(dbg, func, frameIndex, expectedLocation) {
+ const { threadFront } = dbg;
+
+ await invokeAndPause(dbg, `${func}()`);
+ await steps(threadFront, [stepOver, stepIn, stepOver, stepOver]);
+
+ const { frames } = await threadFront.frames(0, 5);
+ const frameActorID = frames[frameIndex].actorID;
+ const packet = await stepOut(threadFront, frameActorID);
+
+ deepEqual(
+ getPauseLocation(packet),
+ expectedLocation,
+ `step out location in ${func}`
+ );
+
+ await resumeAndWaitForPause(threadFront);
+ await dbg.threadFront.resume();
+}
+
+function run_test() {
+ return (async function () {
+ const dbg = await setupTestFromUrl("stepping-async.js");
+
+ info(`Test stepping out before timer;`);
+ await stepOutBeforeTimer(dbg, "stuff", 0, { line: 27, column: 2 });
+
+ info(`Test stepping out after timer;`);
+ await stepOutAfterTimer(dbg, "stuff", 0, { line: 29, column: 2 });
+
+ await testFinish(dbg);
+ })();
+}
diff --git a/devtools/server/tests/xpcshell/test_stepping-with-skip-breakpoints.js b/devtools/server/tests/xpcshell/test_stepping-with-skip-breakpoints.js
new file mode 100644
index 0000000000..3ec4fd994d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_stepping-with-skip-breakpoints.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check basic step-over functionality with pause points
+ * for the first statement and end of the last statement.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ dumpn("Evaluating test code and waiting for first debugger statement");
+ const dbgStmt = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+ equal(
+ dbgStmt.frame.where.line,
+ 2,
+ "Should be at debugger statement on line 2"
+ );
+ equal(debuggee.a, undefined);
+ equal(debuggee.b, undefined);
+
+ const source = await getSource(
+ threadFront,
+ "test_stepping-01-test-code.js"
+ );
+
+ // Add pause points for the first and end of the last statement.
+ // Note: we intentionally ignore the second statement.
+ source.setPausePoints([
+ {
+ location: { line: 3, column: 8 },
+ types: { breakpoint: true, stepOver: true },
+ },
+ {
+ location: { line: 4, column: 14 },
+ types: { breakpoint: true, stepOver: true },
+ },
+ ]);
+
+ dumpn("Step Over to line 3");
+ const step1 = await stepOver(threadFront);
+ equal(step1.why.type, "resumeLimit");
+ equal(step1.frame.where.line, 3);
+ equal(step1.frame.where.column, 12);
+
+ equal(debuggee.a, undefined);
+ equal(debuggee.b, undefined);
+
+ dumpn("Step Over to line 4");
+ const step2 = await stepOver(threadFront);
+ equal(step2.why.type, "resumeLimit");
+ equal(step2.frame.where.line, 4);
+ equal(step2.frame.where.column, 12);
+
+ equal(debuggee.a, 1);
+ equal(debuggee.b, undefined);
+
+ dumpn("Step Over to the end of line 4");
+ const step3 = await stepOver(threadFront);
+ equal(step3.why.type, "resumeLimit");
+ equal(step3.frame.where.line, 4);
+ equal(step3.frame.where.column, 14);
+ equal(debuggee.a, 1);
+ equal(debuggee.b, 2);
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ // prettier-ignore
+ Cu.evalInSandbox(
+ ` // 1
+ debugger; // 2
+ var a = 1; // 3
+ var b = 2;`, // 4
+ debuggee,
+ "1.8",
+ "test_stepping-01-test-code.js",
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_symbolactor.js b/devtools/server/tests/xpcshell/test_symbolactor.js
new file mode 100644
index 0000000000..0d04a2bd1d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_symbolactor.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ SymbolActor,
+} = require("resource://devtools/server/actors/object/symbol.js");
+
+function run_test() {
+ test_SA_destroy();
+ test_SA_form();
+ test_SA_raw();
+}
+
+const SYMBOL_NAME = "abc";
+const TEST_SYMBOL = Symbol(SYMBOL_NAME);
+
+function makeMockSymbolActor() {
+ const symbol = TEST_SYMBOL;
+ const mockConn = null;
+ const actor = new SymbolActor(mockConn, symbol);
+ actor.actorID = "symbol1";
+ const parentPool = {
+ symbolActors: {
+ [symbol]: actor,
+ },
+ unmanage: () => {},
+ };
+ actor.getParent = () => parentPool;
+ return actor;
+}
+
+function test_SA_destroy() {
+ const actor = makeMockSymbolActor();
+ strictEqual(actor.getParent().symbolActors[TEST_SYMBOL], actor);
+
+ actor.destroy();
+ strictEqual(TEST_SYMBOL in actor.getParent().symbolActors, false);
+}
+
+function test_SA_form() {
+ const actor = makeMockSymbolActor();
+ const form = actor.form();
+ strictEqual(form.type, "symbol");
+ strictEqual(form.actor, actor.actorID);
+ strictEqual(form.name, SYMBOL_NAME);
+}
+
+function test_SA_raw() {
+ const actor = makeMockSymbolActor();
+ strictEqual(actor.rawValue(), TEST_SYMBOL);
+}
diff --git a/devtools/server/tests/xpcshell/test_symbols-01.js b/devtools/server/tests/xpcshell/test_symbols-01.js
new file mode 100644
index 0000000000..5352542e83
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_symbols-01.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that we can represent ES6 Symbols over the RDP.
+ */
+
+const URL = "foo.js";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await testSymbols(threadFront, debuggee);
+ })
+);
+
+async function testSymbols(threadFront, debuggee) {
+ const evalCode = () => {
+ /* eslint-disable mozilla/var-only-at-top-level, no-unused-vars */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "(" + function () {
+ var symbolWithName = Symbol("Chris");
+ var symbolWithoutName = Symbol();
+ var iteratorSymbol = Symbol.iterator;
+ debugger;
+ } + "())",
+ debuggee,
+ "1.8",
+ URL,
+ 1
+ );
+ /* eslint-enable mozilla/var-only-at-top-level, no-unused-vars */
+ };
+
+ const packet = await executeOnNextTickAndWaitForPause(evalCode, threadFront);
+ const environment = await packet.frame.getEnvironment();
+ const { symbolWithName, symbolWithoutName, iteratorSymbol } =
+ environment.bindings.variables;
+
+ equal(symbolWithName.value.type, "symbol");
+ equal(symbolWithName.value.name, "Chris");
+
+ equal(symbolWithoutName.value.type, "symbol");
+ ok(!("name" in symbolWithoutName.value));
+
+ equal(iteratorSymbol.value.type, "symbol");
+ equal(iteratorSymbol.value.name, "Symbol.iterator");
+}
diff --git a/devtools/server/tests/xpcshell/test_symbols-02.js b/devtools/server/tests/xpcshell/test_symbols-02.js
new file mode 100644
index 0000000000..12d4ef80c8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_symbols-02.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that we don't run debuggee code when getting symbol names.
+ */
+
+const URL = "foo.js";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await testSymbols(threadFront, debuggee);
+ })
+);
+
+async function testSymbols(threadFront, debuggee) {
+ const evalCode = () => {
+ /* eslint-disable mozilla/var-only-at-top-level, no-extend-native, no-unused-vars */
+ // prettier-ignore
+ Cu.evalInSandbox(
+ "(" + function () {
+ Symbol.prototype.toString = () => {
+ throw new Error("lololol");
+ };
+ var sym = Symbol("le troll");
+ debugger;
+ } + "())",
+ debuggee,
+ "1.8",
+ URL,
+ 1
+ );
+ /* eslint-enable mozilla/var-only-at-top-level, no-extend-native, no-unused-vars */
+ };
+
+ const packet = await executeOnNextTickAndWaitForPause(evalCode, threadFront);
+ const environment = await packet.frame.getEnvironment();
+ const { sym } = environment.bindings.variables;
+
+ equal(sym.value.type, "symbol");
+ equal(sym.value.name, "le troll");
+}
diff --git a/devtools/server/tests/xpcshell/test_threadlifetime-01.js b/devtools/server/tests/xpcshell/test_threadlifetime-01.js
new file mode 100644
index 0000000000..d2e8234fb9
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_threadlifetime-01.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that thread-lifetime grips last past a resume.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const pauseGrip = packet.frame.arguments[0];
+
+ // Create a thread-lifetime actor for this object.
+ const response = await client.request({
+ to: pauseGrip.actor,
+ type: "threadGrip",
+ });
+ // Successful promotion won't return an error.
+ Assert.equal(response.error, undefined);
+
+ const packet2 = await resumeAndWaitForPause(threadFront);
+
+ // Verify that the promoted actor is returned again.
+ Assert.equal(pauseGrip.actor, packet2.frame.arguments[0].actor);
+ // Now that we've resumed, should get unrecognizePacketType for the
+ // promoted grip.
+ try {
+ await client.request({ to: pauseGrip.actor, type: "bogusRequest" });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ Assert.equal(e.error, "unrecognizedPacketType");
+ ok(true, "bogusRequest thrown");
+ }
+ await threadFront.resume();
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(arg1) {
+ debugger;
+ debugger;
+ }
+ stopMe({ obj: true });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_threadlifetime-02.js b/devtools/server/tests/xpcshell/test_threadlifetime-02.js
new file mode 100644
index 0000000000..c35350a48c
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_threadlifetime-02.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that thread-lifetime grips last past a resume.
+ */
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee, client }) => {
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ const pauseGrip = packet.frame.arguments[0];
+
+ // Create a thread-lifetime actor for this object.
+ const response = await client.request({
+ to: pauseGrip.actor,
+ type: "threadGrip",
+ });
+ // Successful promotion won't return an error.
+ Assert.equal(response.error, undefined);
+
+ const packet2 = await resumeAndWaitForPause(threadFront);
+
+ // Verify that the promoted actor is returned again.
+ Assert.equal(pauseGrip.actor, packet2.frame.arguments[0].actor);
+ // Now that we've resumed, release the thread-lifetime grip.
+ const objFront = new ObjectFront(
+ threadFront.conn,
+ threadFront.targetFront,
+ threadFront,
+ pauseGrip
+ );
+ await objFront.release();
+ const objFront2 = new ObjectFront(
+ threadFront.conn,
+ threadFront.targetFront,
+ threadFront,
+ pauseGrip
+ );
+
+ try {
+ await objFront2
+ .request({ to: pauseGrip.actor, type: "bogusRequest" })
+ .catch(function (error) {
+ Assert.ok(!!error.message.match(/noSuchActor/));
+ threadFront.resume();
+ throw new Error();
+ });
+ ok(false, "bogusRequest should throw");
+ } catch (e) {
+ ok(true, "bogusRequest thrown");
+ }
+ })
+);
+
+function evaluateTestCode(debuggee) {
+ debuggee.eval(
+ "(" +
+ function () {
+ function stopMe(arg1) {
+ debugger;
+ debugger;
+ }
+ stopMe({ obj: true });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_threadlifetime-04.js b/devtools/server/tests/xpcshell/test_threadlifetime-04.js
new file mode 100644
index 0000000000..6b815c7933
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_threadlifetime-04.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Check that requesting a thread-lifetime actor twice for the same
+ * value returns the same actor.
+ */
+
+var gDebuggee;
+var gClient;
+var gThreadFront;
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, client }) => {
+ gThreadFront = threadFront;
+ gClient = client;
+ gDebuggee = debuggee;
+ test_thread_lifetime();
+ },
+ { waitForFinish: true }
+ )
+);
+
+function test_thread_lifetime() {
+ gThreadFront.once("paused", async function (packet) {
+ const pauseGrip = packet.frame.arguments[0];
+
+ const response = await gClient.request({
+ to: pauseGrip.actor,
+ type: "threadGrip",
+ });
+ const threadGrip1 = response.from;
+
+ const response2 = await gClient.request({
+ to: pauseGrip.actor,
+ type: "threadGrip",
+ });
+ Assert.equal(threadGrip1, response2.from);
+ await gThreadFront.resume();
+
+ threadFrontTestFinished();
+ });
+
+ gDebuggee.eval(
+ "(" +
+ function () {
+ function stopMe(arg1) {
+ debugger;
+ }
+ stopMe({ obj: true });
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_unsafeDereference.js b/devtools/server/tests/xpcshell/test_unsafeDereference.js
new file mode 100644
index 0000000000..53b70420c6
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_unsafeDereference.js
@@ -0,0 +1,130 @@
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/publicdomain/zero/1.0/
+
+/* eslint-disable strict */
+
+// Test Debugger.Object.prototype.unsafeDereference in the presence of
+// interesting cross-compartment wrappers.
+//
+// This is not really a devtools server test; it's more of a Debugger test.
+// But we need xpcshell and Components.utils.Sandbox to get
+// cross-compartment wrappers with interesting properties, and this is the
+// xpcshell test directory most closely related to the JS Debugger API.
+
+addDebuggerToGlobal(globalThis);
+
+// Add a method to Debugger.Object for fetching value properties
+// conveniently.
+Debugger.Object.prototype.getProperty = function (name) {
+ const desc = this.getOwnPropertyDescriptor(name);
+ if (!desc) {
+ return undefined;
+ }
+ if (!desc.value) {
+ throw Error(
+ "Debugger.Object.prototype.getProperty: " +
+ "not a value property: " +
+ name
+ );
+ }
+ return desc.value;
+};
+
+function run_test() {
+ // Create a low-privilege sandbox, and a chrome-privilege sandbox.
+ const contentBox = Cu.Sandbox("http://www.example.com");
+ const chromeBox = Cu.Sandbox(this);
+
+ // Create an objects in this compartment, and one in each sandbox. We'll
+ // refer to the objects as "mainObj", "contentObj", and "chromeObj", in
+ // variable and property names.
+ const mainObj = { name: "mainObj" };
+ Cu.evalInSandbox('var contentObj = { name: "contentObj" };', contentBox);
+ Cu.evalInSandbox('var chromeObj = { name: "chromeObj" };', chromeBox);
+
+ // Give each global a pointer to all the other globals' objects.
+ contentBox.mainObj = chromeBox.mainObj = mainObj;
+ const contentObj = (chromeBox.contentObj = contentBox.contentObj);
+ const chromeObj = (contentBox.chromeObj = chromeBox.chromeObj);
+
+ // First, a whole bunch of basic sanity checks, to ensure that JavaScript
+ // evaluated in various scopes really does see the world the way this
+ // test expects it to.
+
+ // The objects appear as global variables in the sandbox, and as
+ // the sandbox object's properties in chrome.
+ Assert.ok(Cu.evalInSandbox("mainObj", contentBox) === contentBox.mainObj);
+ Assert.ok(
+ Cu.evalInSandbox("contentObj", contentBox) === contentBox.contentObj
+ );
+ Assert.ok(Cu.evalInSandbox("chromeObj", contentBox) === contentBox.chromeObj);
+ Assert.ok(Cu.evalInSandbox("mainObj", chromeBox) === chromeBox.mainObj);
+ Assert.ok(Cu.evalInSandbox("contentObj", chromeBox) === chromeBox.contentObj);
+ Assert.ok(Cu.evalInSandbox("chromeObj", chromeBox) === chromeBox.chromeObj);
+
+ // We (the main global) can see properties of all objects in all globals.
+ Assert.ok(contentBox.mainObj.name === "mainObj");
+ Assert.ok(contentBox.contentObj.name === "contentObj");
+ Assert.ok(contentBox.chromeObj.name === "chromeObj");
+
+ // chromeBox can see properties of all objects in all globals.
+ Assert.equal(Cu.evalInSandbox("mainObj.name", chromeBox), "mainObj");
+ Assert.equal(Cu.evalInSandbox("contentObj.name", chromeBox), "contentObj");
+ Assert.equal(Cu.evalInSandbox("chromeObj.name", chromeBox), "chromeObj");
+
+ // contentBox can see properties of the content object, but not of either
+ // chrome object, because by default, content -> chrome wrappers hide all
+ // object properties.
+ Assert.equal(Cu.evalInSandbox("mainObj.name", contentBox), undefined);
+ Assert.equal(Cu.evalInSandbox("contentObj.name", contentBox), "contentObj");
+ Assert.equal(Cu.evalInSandbox("chromeObj.name", contentBox), undefined);
+
+ // When viewing an object in compartment A from the vantage point of
+ // compartment B, Debugger should give the same results as debuggee code
+ // would.
+
+ // Create a debugger, debugging our two sandboxes.
+ const dbg = new Debugger();
+
+ // Create Debugger.Object instances referring to the two sandboxes, as
+ // seen from their own compartments.
+ const contentBoxDO = dbg.addDebuggee(contentBox);
+ const chromeBoxDO = dbg.addDebuggee(chromeBox);
+
+ // Use Debugger to view the objects from contentBox. We should get the
+ // same D.O instance from both getProperty and makeDebuggeeValue, and the
+ // same property visibility we checked for above.
+ const mainFromContentDO = contentBoxDO.getProperty("mainObj");
+ Assert.equal(mainFromContentDO, contentBoxDO.makeDebuggeeValue(mainObj));
+ Assert.equal(mainFromContentDO.getProperty("name"), undefined);
+ Assert.equal(mainFromContentDO.unsafeDereference(), mainObj);
+
+ const contentFromContentDO = contentBoxDO.getProperty("contentObj");
+ Assert.equal(
+ contentFromContentDO,
+ contentBoxDO.makeDebuggeeValue(contentObj)
+ );
+ Assert.equal(contentFromContentDO.getProperty("name"), "contentObj");
+ Assert.equal(contentFromContentDO.unsafeDereference(), contentObj);
+
+ const chromeFromContentDO = contentBoxDO.getProperty("chromeObj");
+ Assert.equal(chromeFromContentDO, contentBoxDO.makeDebuggeeValue(chromeObj));
+ Assert.equal(chromeFromContentDO.getProperty("name"), undefined);
+ Assert.equal(chromeFromContentDO.unsafeDereference(), chromeObj);
+
+ // Similarly, viewing from chromeBox.
+ const mainFromChromeDO = chromeBoxDO.getProperty("mainObj");
+ Assert.equal(mainFromChromeDO, chromeBoxDO.makeDebuggeeValue(mainObj));
+ Assert.equal(mainFromChromeDO.getProperty("name"), "mainObj");
+ Assert.equal(mainFromChromeDO.unsafeDereference(), mainObj);
+
+ const contentFromChromeDO = chromeBoxDO.getProperty("contentObj");
+ Assert.equal(contentFromChromeDO, chromeBoxDO.makeDebuggeeValue(contentObj));
+ Assert.equal(contentFromChromeDO.getProperty("name"), "contentObj");
+ Assert.equal(contentFromChromeDO.unsafeDereference(), contentObj);
+
+ const chromeFromChromeDO = chromeBoxDO.getProperty("chromeObj");
+ Assert.equal(chromeFromChromeDO, chromeBoxDO.makeDebuggeeValue(chromeObj));
+ Assert.equal(chromeFromChromeDO.getProperty("name"), "chromeObj");
+ Assert.equal(chromeFromChromeDO.unsafeDereference(), chromeObj);
+}
diff --git a/devtools/server/tests/xpcshell/test_wasm_source-01.js b/devtools/server/tests/xpcshell/test_wasm_source-01.js
new file mode 100644
index 0000000000..fe8e43e236
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_wasm_source-01.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+/**
+ * Verify if client can receive binary wasm
+ */
+
+var gDebuggee;
+var gThreadFront;
+
+add_task(
+ threadFrontTest(
+ async ({ threadFront, debuggee, client }) => {
+ gThreadFront = threadFront;
+ gDebuggee = debuggee;
+
+ await gThreadFront.reconfigure({
+ observeAsmJS: true,
+ observeWasm: true,
+ });
+
+ test_source();
+ },
+ { waitForFinish: true, doNotRunWorker: true }
+ )
+);
+
+const EXPECTED_CONTENT = String.fromCharCode(
+ 0,
+ 97,
+ 115,
+ 109,
+ 1,
+ 0,
+ 0,
+ 0,
+ 1,
+ 132,
+ 128,
+ 128,
+ 128,
+ 0,
+ 1,
+ 96,
+ 0,
+ 0,
+ 3,
+ 130,
+ 128,
+ 128,
+ 128,
+ 0,
+ 1,
+ 0,
+ 6,
+ 129,
+ 128,
+ 128,
+ 128,
+ 0,
+ 0,
+ 7,
+ 133,
+ 128,
+ 128,
+ 128,
+ 0,
+ 1,
+ 1,
+ 102,
+ 0,
+ 0,
+ 10,
+ 136,
+ 128,
+ 128,
+ 128,
+ 0,
+ 1,
+ 130,
+ 128,
+ 128,
+ 128,
+ 0,
+ 0,
+ 11
+);
+
+function test_source() {
+ gThreadFront.once("paused", function (packet) {
+ gThreadFront.getSources().then(function (response) {
+ Assert.ok(!!response);
+ Assert.ok(!!response.sources);
+
+ const source = response.sources.filter(function (s) {
+ return s.introductionType === "wasm";
+ })[0];
+
+ Assert.ok(!!source);
+
+ const sourceFront = gThreadFront.source(source);
+ sourceFront.source().then(function (response) {
+ Assert.ok(!!response);
+ Assert.ok(!!response.contentType);
+ Assert.ok(response.contentType.includes("wasm"));
+
+ const sourceContent = response.source;
+ Assert.ok(!!sourceContent);
+ Assert.equal(typeof sourceContent, "object");
+ Assert.ok("binary" in sourceContent);
+ Assert.equal(EXPECTED_CONTENT, sourceContent.binary);
+
+ gThreadFront.resume().then(function () {
+ threadFrontTestFinished();
+ });
+ });
+ });
+ });
+
+ /* eslint-disable comma-spacing, max-len */
+ gDebuggee.eval(
+ "(" +
+ function () {
+ // WebAssembly bytecode was generated by running:
+ // js -e 'print(wasmTextToBinary("(module(func(export \"f\")))"))'
+ const m = new WebAssembly.Module(
+ new Uint8Array([
+ 0, 97, 115, 109, 1, 0, 0, 0, 1, 132, 128, 128, 128, 0, 1, 96, 0, 0,
+ 3, 130, 128, 128, 128, 0, 1, 0, 6, 129, 128, 128, 128, 0, 0, 7, 133,
+ 128, 128, 128, 0, 1, 1, 102, 0, 0, 10, 136, 128, 128, 128, 0, 1,
+ 130, 128, 128, 128, 0, 0, 11,
+ ])
+ );
+ const i = new WebAssembly.Instance(m);
+ debugger;
+ i.exports.f();
+ } +
+ ")()"
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_watchpoint-01.js b/devtools/server/tests/xpcshell/test_watchpoint-01.js
new file mode 100644
index 0000000000..2d1d0e78f4
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_watchpoint-01.js
@@ -0,0 +1,197 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow */
+
+"use strict";
+
+/*
+- Tests adding set and get watchpoints.
+- Tests removing a watchpoint.
+- Tests removing all watchpoints.
+*/
+
+add_task(
+ threadFrontTest(async args => {
+ await testSetWatchpoint(args);
+ await testGetWatchpoint(args);
+ await testRemoveWatchpoint(args);
+ await testRemoveWatchpoints(args);
+ })
+);
+
+async function testSetWatchpoint({ commands, threadFront, debuggee }) {
+ async function evaluateJS(input) {
+ const { result } = await commands.scriptCommand.execute(input, {
+ thread: threadFront.actor,
+ frameActor: packet.frame.actorID,
+ });
+ return result;
+ }
+
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a = 2; // 4
+ } //
+ stopMe({a: { b: 1 }})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-01.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we paused on the debugger statement");
+ Assert.equal(packet.frame.where.line, 3);
+
+ info("Add set watchpoint");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "set");
+
+ let result = await evaluateJS("obj.a");
+ Assert.equal(result.getGrip().preview.ownProperties.b.value, 1);
+
+ result = await evaluateJS("obj.a.b");
+ Assert.equal(result, 1);
+
+ info("Test that watchpoint triggers pause on set");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.why.type, "setWatchpoint");
+ Assert.equal(obj.preview.ownProperties.a.value.ownPropertyLength, 1);
+
+ await resume(threadFront);
+}
+
+async function testGetWatchpoint({ threadFront, debuggee }) {
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a + 4; // 4
+ } //
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-01.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we paused on the debugger statement");
+ Assert.equal(packet.frame.where.line, 3);
+
+ info("Add get watchpoint.");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "get");
+
+ info("Test that watchpoint triggers pause on get.");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.why.type, "getWatchpoint");
+ Assert.equal(obj.preview.ownProperties.a.value, 1);
+
+ await resume(threadFront);
+}
+
+async function testRemoveWatchpoint({ threadFront, debuggee }) {
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a = 2; // 4
+ debugger; // 5
+ } //
+
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-01.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info(`Test that we paused on the debugger statement`);
+ Assert.equal(packet.frame.where.line, 3);
+
+ info(`Add set watchpoint`);
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "set");
+
+ info(`Remove set watchpoint`);
+ await objClient.removeWatchpoint("a");
+
+ info(`Test that we do not pause on set`);
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 5);
+
+ await resume(threadFront);
+}
+
+async function testRemoveWatchpoints({ threadFront, debuggee }) {
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a = 2; // 4
+ debugger; // 5
+ } //
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-01.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we paused on the debugger statement");
+ Assert.equal(packet.frame.where.line, 3);
+
+ info("Add and then remove set watchpoint");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "set");
+ await objClient.removeWatchpoints();
+
+ info("Test that we do not pause on set");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 5);
+
+ await resume(threadFront);
+}
diff --git a/devtools/server/tests/xpcshell/test_watchpoint-02.js b/devtools/server/tests/xpcshell/test_watchpoint-02.js
new file mode 100644
index 0000000000..d0739c8a00
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_watchpoint-02.js
@@ -0,0 +1,223 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow */
+
+"use strict";
+
+/*
+Test that debugger advances instead of pausing twice on the
+same line when encountering both a watchpoint and a breakpoint.
+*/
+
+add_task(
+ threadFrontTest(async args => {
+ await testBreakpointAndSetWatchpoint(args);
+ await testBreakpointAndGetWatchpoint(args);
+ await testLoops(args);
+ })
+);
+
+// Test that we advance to the next line when a location
+// has both a breakpoint and set watchpoint.
+async function testBreakpointAndSetWatchpoint({
+ commands,
+ threadFront,
+ debuggee,
+}) {
+ async function evaluateJS(input) {
+ const { result } = await commands.scriptCommand.execute(input, {
+ frameActor: packet.frame.actorID,
+ });
+ return result;
+ }
+
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a = 2; // 4
+ debugger; // 5
+ } //
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-02.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we pause on the debugger statement.");
+ Assert.equal(packet.frame.where.line, 3);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ info("Add set watchpoint.");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "set");
+
+ info("Add breakpoint.");
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+
+ const location = {
+ sourceUrl: source.url,
+ line: 4,
+ };
+
+ threadFront.setBreakpoint(location, {});
+
+ info("Test that pause occurs on breakpoint.");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.why.type, "breakpoint");
+
+ const packet3 = await resumeAndWaitForPause(threadFront);
+
+ info("Test that we pause on the second debugger statement.");
+ Assert.equal(packet3.frame.where.line, 5);
+ Assert.equal(packet3.why.type, "debuggerStatement");
+
+ info("Test that the value has updated.");
+ const result = await evaluateJS("obj.a");
+ Assert.equal(result, 2);
+
+ info("Remove breakpoint and finish.");
+ threadFront.removeBreakpoint(location, {});
+
+ await resume(threadFront);
+}
+
+// Test that we advance to the next line when a location
+// has both a breakpoint and get watchpoint.
+async function testBreakpointAndGetWatchpoint({ threadFront, debuggee }) {
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a + 4; // 4
+ debugger; // 5
+ } //
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-02.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we pause on the debugger statement.");
+ Assert.equal(packet.frame.where.line, 3);
+
+ info("Add get watchpoint.");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "get");
+
+ info("Add breakpoint.");
+ const source = await getSourceById(threadFront, packet.frame.where.actor);
+
+ const location = {
+ sourceUrl: source.url,
+ line: 4,
+ };
+
+ threadFront.setBreakpoint(location, {});
+
+ info("Test that pause occurs on breakpoint.");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.why.type, "breakpoint");
+
+ const packet3 = await resumeAndWaitForPause(threadFront);
+
+ info("Test that we pause on the second debugger statement.");
+ Assert.equal(packet3.frame.where.line, 5);
+ Assert.equal(packet3.why.type, "debuggerStatement");
+
+ info("Remove breakpoint and finish.");
+ threadFront.removeBreakpoint(location, {});
+
+ await resume(threadFront);
+}
+
+// Test that we can pause multiple times
+// on the same line for a watchpoint.
+async function testLoops({ commands, threadFront, debuggee }) {
+ async function evaluateJS(input) {
+ const { result } = await commands.scriptCommand.execute(input, {
+ frameActor: packet.frame.actorID,
+ });
+ return result;
+ }
+
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ let i = 0; // 3
+ debugger; // 4
+ while (i++ < 2) { // 5
+ obj.a = 2; // 6
+ } // 7
+ debugger; // 8
+ } //
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-02.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we pause on the debugger statement.");
+ Assert.equal(packet.frame.where.line, 4);
+ Assert.equal(packet.why.type, "debuggerStatement");
+
+ info("Add set watchpoint.");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "set");
+
+ info("Test that watchpoint triggers pause on set.");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 6);
+ Assert.equal(packet2.why.type, "setWatchpoint");
+ let result = await evaluateJS("obj.a");
+ Assert.equal(result, 1);
+
+ info("Test that watchpoint triggers pause on set (2nd time).");
+ const packet3 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet3.frame.where.line, 6);
+ Assert.equal(packet3.why.type, "setWatchpoint");
+ let result2 = await evaluateJS("obj.a");
+ Assert.equal(result2, 2);
+
+ info("Test that we pause on second debugger statement.");
+ const packet4 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet4.frame.where.line, 8);
+ Assert.equal(packet4.why.type, "debuggerStatement");
+
+ await resume(threadFront);
+}
diff --git a/devtools/server/tests/xpcshell/test_watchpoint-03.js b/devtools/server/tests/xpcshell/test_watchpoint-03.js
new file mode 100644
index 0000000000..33f4fbd2a2
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_watchpoint-03.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow */
+
+"use strict";
+/*
+See Bug 1601311.
+Tests that removing a watchpoint does not change the value of the property that had the watchpoint.
+*/
+
+add_task(
+ threadFrontTest(async ({ commands, threadFront, debuggee }) => {
+ async function evaluateJS(input) {
+ const { result } = await commands.scriptCommand.execute(input, {
+ frameActor: packet.frame.actorID,
+ });
+ return result;
+ }
+
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a = 2; // 4
+ debugger; // 5
+ } //
+
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-03.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we paused on the debugger statement.");
+ Assert.equal(packet.frame.where.line, 3);
+
+ info("Add set watchpoint.");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "set");
+
+ info("Test that we pause on set.");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+
+ const packet3 = await resumeAndWaitForPause(threadFront);
+
+ info("Test that we pause on the second debugger statement.");
+ Assert.equal(packet3.frame.where.line, 5);
+ Assert.equal(packet3.why.type, "debuggerStatement");
+
+ info("Remove watchpoint.");
+ await objClient.removeWatchpoint("a");
+
+ info("Test that the value has updated.");
+ const result = await evaluateJS("obj.a");
+ Assert.equal(result, 2);
+
+ info("Finish test.");
+ await resume(threadFront);
+ })
+);
diff --git a/devtools/server/tests/xpcshell/test_watchpoint-04.js b/devtools/server/tests/xpcshell/test_watchpoint-04.js
new file mode 100644
index 0000000000..4ee6eadd5a
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_watchpoint-04.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that watchpoints ignore blackboxed sources
+ */
+
+const BLACK_BOXED_URL = "http://example.com/blackboxme.js";
+const SOURCE_URL = "http://example.com/source.js";
+
+add_task(
+ threadFrontTest(async ({ threadFront, debuggee }) => {
+ await executeOnNextTickAndWaitForPause(
+ () => evalCode(debuggee),
+ threadFront
+ );
+
+ info(`blackbox the source`);
+ const { error, sources } = await threadFront.getSources();
+ Assert.ok(!error, "Should not get an error: " + error);
+ const sourceFront = threadFront.source(
+ sources.filter(s => s.url == BLACK_BOXED_URL)[0]
+ );
+
+ await blackBox(sourceFront);
+
+ await threadFront.resume();
+ const packet = await executeOnNextTickAndWaitForPause(
+ debuggee.runTest,
+ threadFront
+ );
+
+ Assert.equal(
+ packet.frame.where.line,
+ 3,
+ "Paused at first debugger statement"
+ );
+
+ await addWatchpoint(threadFront, packet.frame, "obj", "a", "set");
+
+ info(`Resume and skip the watchpoint`);
+ const pausePacket = await resumeAndWaitForPause(threadFront);
+
+ Assert.equal(
+ pausePacket.frame.where.line,
+ 5,
+ "Paused at second debugger statement"
+ );
+
+ await threadFront.resume();
+ })
+);
+
+function evalCode(debuggee) {
+ Cu.evalInSandbox(
+ `function doStuff(obj) {
+ obj.a = 2;
+ }`,
+ debuggee,
+ "1.8",
+ BLACK_BOXED_URL,
+ 1
+ );
+ Cu.evalInSandbox(
+ `function runTest() {
+ const obj = {a: 1}
+ debugger
+ doStuff(obj);
+ debugger
+ }; debugger;`,
+ debuggee,
+ "1.8",
+ SOURCE_URL,
+ 1
+ );
+}
diff --git a/devtools/server/tests/xpcshell/test_watchpoint-05.js b/devtools/server/tests/xpcshell/test_watchpoint-05.js
new file mode 100644
index 0000000000..4d25a59399
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_watchpoint-05.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow */
+
+"use strict";
+
+/*
+- Adds a 'get or set' watchpoint. Tests that the debugger will pause on both get and set.
+*/
+
+add_task(
+ threadFrontTest(async args => {
+ await testGetPauseWithGetOrSetWatchpoint(args);
+ await testSetPauseWithGetOrSetWatchpoint(args);
+ })
+);
+
+async function testGetPauseWithGetOrSetWatchpoint({ threadFront, debuggee }) {
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a + 4; // 4
+ } //
+ stopMe({a: 1})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-05.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we paused on the debugger statement");
+ Assert.equal(packet.frame.where.line, 3);
+
+ info("Add get or set watchpoint.");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "getorset");
+
+ info("Test that watchpoint triggers pause on get.");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.why.type, "getWatchpoint");
+ Assert.equal(obj.preview.ownProperties.a.value, 1);
+
+ await resume(threadFront);
+}
+
+async function testSetPauseWithGetOrSetWatchpoint({
+ commands,
+ threadFront,
+ debuggee,
+}) {
+ async function evaluateJS(input) {
+ const { result } = await commands.scriptCommand.execute(input, {
+ frameActor: packet.frame.actorID,
+ });
+ return result;
+ }
+
+ function evaluateTestCode(debuggee) {
+ /* eslint-disable */
+ Cu.evalInSandbox(
+ ` // 1
+ function stopMe(obj) { // 2
+ debugger; // 3
+ obj.a = 2; // 4
+ } //
+ stopMe({a: { b: 1 }})`,
+ debuggee,
+ "1.8",
+ "test_watchpoint-05.js"
+ );
+ /* eslint-disable */
+ }
+
+ const packet = await executeOnNextTickAndWaitForPause(
+ () => evaluateTestCode(debuggee),
+ threadFront
+ );
+
+ info("Test that we paused on the debugger statement");
+ Assert.equal(packet.frame.where.line, 3);
+
+ info("Add get or set watchpoint");
+ const args = packet.frame.arguments;
+ const obj = args[0];
+ const objClient = threadFront.pauseGrip(obj);
+ await objClient.addWatchpoint("a", "obj.a", "getorset");
+
+ let result = await evaluateJS("obj.a");
+ Assert.equal(result.getGrip().preview.ownProperties.b.value, 1);
+
+ result = await evaluateJS("obj.a.b");
+ Assert.equal(result, 1);
+
+ info("Test that watchpoint triggers pause on set");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ Assert.equal(packet2.frame.where.line, 4);
+ Assert.equal(packet2.why.type, "setWatchpoint");
+ Assert.equal(obj.preview.ownProperties.a.value.ownPropertyLength, 1);
+
+ await resume(threadFront);
+}
diff --git a/devtools/server/tests/xpcshell/test_webext_apis.js b/devtools/server/tests/xpcshell/test_webext_apis.js
new file mode 100644
index 0000000000..5a2f2b990a
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_webext_apis.js
@@ -0,0 +1,162 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+const DistinctDevToolsServer = getDistinctDevToolsServer();
+ExtensionTestUtils.init(this);
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.blocklist.enabled", false);
+ await startupAddonsManager();
+});
+
+// Basic request wrapper that sends a request and resolves on the next packet.
+// Will only work for very basic scenarios, without events emitted on the server
+// etc...
+async function sendRequest(transport, request) {
+ return new Promise(resolve => {
+ transport.hooks = {
+ onPacket: packet => {
+ dump(`received packet: ${JSON.stringify(packet)}\n`);
+ // Let's resolve only when we get a packet that is related to our
+ // request. It is needed because some methods do not return the correct
+ // response right away. This is the case of the `reload` method, which
+ // receives a `addonListChanged` message first and then a `reload`
+ // message.
+ if (packet.from === request.to) {
+ resolve(packet);
+ }
+ },
+ };
+ transport.send(request);
+ });
+}
+
+// If this test case fails, please reach out to webext peers because
+// https://github.com/mozilla/web-ext relies on the APIs tested here.
+add_task(async function test_webext_run_apis() {
+ DistinctDevToolsServer.init();
+ DistinctDevToolsServer.registerAllActors();
+
+ const transport = DistinctDevToolsServer.connectPipe();
+
+ // After calling connectPipe, the root actor will be created on the server
+ // and a packet will be emitted after a tick. Wait for the initial packet.
+ await new Promise(resolve => {
+ transport.hooks = { onPacket: resolve };
+ });
+
+ const getRootResponse = await sendRequest(transport, {
+ to: "root",
+ type: "getRoot",
+ });
+
+ ok(getRootResponse, "received a response after calling RootActor::getRoot");
+ ok(getRootResponse.addonsActor, "getRoot returned an addonsActor id");
+
+ // installTemporaryAddon
+ const addonId = "test-addons-actor@mozilla.org";
+ const addonPath = getFilePath("addons/web-extension", false, true);
+ const promiseStarted = AddonTestUtils.promiseWebExtensionStartup(addonId);
+ const { addon } = await sendRequest(transport, {
+ to: getRootResponse.addonsActor,
+ type: "installTemporaryAddon",
+ addonPath,
+ // The openDevTools parameter is not always passed by web-ext. This test
+ // omits it, to make sure that the request without the flag is accepted.
+ // openDevTools: false,
+ });
+ await promiseStarted;
+
+ ok(addon, "addonsActor allows to install a temporary add-on");
+ equal(addon.id, addonId, "temporary add-on is the expected one");
+ equal(addon.actor, false, "temporary add-on does not have an actor");
+
+ // listAddons
+ let { addons } = await sendRequest(transport, {
+ to: "root",
+ type: "listAddons",
+ });
+ ok(Array.isArray(addons), "listAddons() returns a list of add-ons");
+ equal(addons.length, 1, "expected an add-on installed");
+
+ const installedAddon = addons[0];
+ equal(installedAddon.id, addonId, "installed add-on is the expected one");
+ ok(installedAddon.actor, "returned add-on has an actor");
+
+ // reload
+ const promiseReloaded = AddonTestUtils.promiseAddonEvent("onInstalled");
+ const promiseRestarted = AddonTestUtils.promiseWebExtensionStartup(addonId);
+ await sendRequest(transport, {
+ to: installedAddon.actor,
+ type: "reload",
+ });
+ await Promise.all([promiseReloaded, promiseRestarted]);
+
+ // uninstallAddon
+ const promiseUninstalled = new Promise(resolve => {
+ const listener = {};
+ listener.onUninstalled = uninstalledAddon => {
+ if (uninstalledAddon.id == addonId) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ };
+ AddonManager.addAddonListener(listener);
+ });
+ await sendRequest(transport, {
+ to: getRootResponse.addonsActor,
+ type: "uninstallAddon",
+ addonId,
+ });
+ await promiseUninstalled;
+
+ ({ addons } = await sendRequest(transport, {
+ to: "root",
+ type: "listAddons",
+ }));
+ equal(addons.length, 0, "expected no add-on installed");
+
+ // Attempt to uninstall an add-on that is (no longer) installed.
+ let error = await sendRequest(transport, {
+ to: getRootResponse.addonsActor,
+ type: "uninstallAddon",
+ addonId,
+ });
+ equal(
+ error?.message,
+ `Could not uninstall add-on "${addonId}"`,
+ "expected error"
+ );
+
+ // Attempt to uninstall a non-temporarily loaded extension, which we do not
+ // allow at the moment. We start by loading an extension, then we call the
+ // `uninstallAddon`.
+ const id = "not-a-temporary@extension";
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id } },
+ },
+ useAddonManager: "permanent",
+ });
+ await extension.startup();
+
+ error = await sendRequest(transport, {
+ to: getRootResponse.addonsActor,
+ type: "uninstallAddon",
+ addonId: id,
+ });
+ equal(error?.message, `Could not uninstall add-on "${id}"`, "expected error");
+
+ await extension.unload();
+
+ transport.close();
+});
diff --git a/devtools/server/tests/xpcshell/test_webextension_descriptor.js b/devtools/server/tests/xpcshell/test_webextension_descriptor.js
new file mode 100644
index 0000000000..00cdeea605
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_webextension_descriptor.js
@@ -0,0 +1,141 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+const DistinctDevToolsServer = getDistinctDevToolsServer();
+ExtensionTestUtils.init(this);
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.blocklist.enabled", false);
+ await startupAddonsManager();
+
+ // We intentionally generate install-time manifest warnings, so don't trigger
+ // the special test-only mode of converting them to errors.
+ Services.prefs.setBoolPref(
+ "extensions.webextensions.warnings-as-errors",
+ false
+ );
+
+ DistinctDevToolsServer.init();
+ DistinctDevToolsServer.registerAllActors();
+});
+
+// Verifies:
+// - listAddons
+// - WebExtensionDescriptorActor output
+// Also a regression test for bug 1837185, that AddonManager.sys.mjs and
+// ExtensionParent.sys.mjs are imported from the correct loader.
+add_task(async function test_listAddons_and_WebExtensionDescriptor() {
+ const transport = DistinctDevToolsServer.connectPipe();
+ const client = new DevToolsClient(transport);
+ await client.connect();
+
+ const getRootResponse = await client.mainRoot.getRoot();
+
+ ok(getRootResponse, "received a response after calling RootActor::getRoot");
+ ok(getRootResponse.addonsActor, "getRoot returned an addonsActor id");
+
+ const ADDON_ID = "with@warning";
+ const extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ name: "DummyExtensionWithUnknownManifestKey",
+ unknown_manifest_key: "this is an unknown manifest key",
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ },
+ background: `browser.test.sendMessage("background_started");`,
+ });
+ await extension.startup();
+ await extension.awaitMessage("background_started");
+
+ // listAddons: addon after new install.
+ {
+ const listAddonsResponse = await client.mainRoot.listAddons();
+ const addon = listAddonsResponse.find(a => a.id === ADDON_ID);
+ ok(addon, "listAddons() returns a list of add-ons including with@warning");
+
+ // Inspect all raw properties of the message, to make sure that we always
+ // have full coverage for all current and future properties.
+ const { actor, url, warnings, ...addonMinusSomeKeys } = addon._form;
+ const actorPattern = /^server\d+\.conn\d+\.webExtensionDescriptor\d+$/;
+ ok(actorPattern.test(actor), `actor is webExtensionDescriptor: ${actor}`);
+ // We don't care about the exact path, just a dummy check:
+ ok(url.endsWith(".xpi"), `url is path to the xpi file`);
+
+ deepEqual(
+ warnings,
+ [
+ "Reading manifest: Warning processing unknown_manifest_key: An unexpected property was found in the WebExtension manifest.",
+ ],
+ "Can retrieve warnings."
+ );
+
+ // Verify that the other remaining keys have a meaningful value.
+ // This is mainly to have some form of verification on the value of the
+ // properties. If this check ever fails, double-check whether the proposed
+ // change makes sense and if it does just update the test expectation here.
+ deepEqual(
+ addonMinusSomeKeys,
+ {
+ backgroundScriptStatus: undefined,
+ debuggable: true,
+ hidden: false,
+ iconDataURL: undefined,
+ iconURL: null,
+ id: ADDON_ID,
+ isSystem: false,
+ isWebExtension: true,
+ manifestURL: `moz-extension://${extension.uuid}/manifest.json`,
+ name: "DummyExtensionWithUnknownManifestKey",
+ persistentBackgroundScript: true,
+ temporarilyInstalled: false,
+ traits: {
+ supportsReloadDescriptor: true,
+ watcher: true,
+ },
+ },
+ "WebExtensionDescriptorActor content matches the add-on"
+ );
+ }
+
+ await extension.upgrade({
+ manifest: {
+ name: "Updated_extension",
+ new_unknown_manifest_key: "different warning than before",
+ browser_specific_settings: { gecko: { id: ADDON_ID } },
+ },
+ background: `browser.test.sendMessage("updated_done");`,
+ });
+ await extension.awaitMessage("updated_done");
+
+ // listAddons: addon after update.
+ {
+ const listAddonsResponse = await client.mainRoot.listAddons();
+ const addon = listAddonsResponse.find(a => a.id === ADDON_ID);
+ ok(addon, "listAddons() should still list the add-on after update");
+ equal(addon.name, "Updated_extension", "Got updated name");
+ deepEqual(
+ addon.warnings,
+ [
+ "Reading manifest: Warning processing new_unknown_manifest_key: An unexpected property was found in the WebExtension manifest.",
+ ],
+ "Can retrieve new warnings for updated add-on."
+ );
+ }
+
+ await extension.unload();
+
+ // listAddons: addon after removal - gone.
+ {
+ const listAddonsResponse = await client.mainRoot.listAddons();
+ const addon = listAddonsResponse.find(a => a.id === ADDON_ID);
+ deepEqual(addon, null, "Add-on should be gone after removal");
+ }
+
+ await client.close();
+});
diff --git a/devtools/server/tests/xpcshell/test_xpcshell_debugging.js b/devtools/server/tests/xpcshell/test_xpcshell_debugging.js
new file mode 100644
index 0000000000..ff54d7390d
--- /dev/null
+++ b/devtools/server/tests/xpcshell/test_xpcshell_debugging.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the xpcshell-test debug support. Ideally we should have this test
+// next to the xpcshell support code, but that's tricky...
+
+// HACK: ServiceWorkerManager requires the "profile-change-teardown" to cleanly
+// shutdown, and setting _profileInitialized to `true` will trigger those
+// notifications (see /testing/xpcshell/head.js).
+// eslint-disable-next-line no-undef
+_profileInitialized = true;
+
+add_task(async function () {
+ const testFile = do_get_file("xpcshell_debugging_script.js");
+
+ // _setupDevToolsServer is from xpcshell-test's head.js
+ /* global _setupDevToolsServer */
+ let testInitialized = false;
+ const { DevToolsServer } = _setupDevToolsServer([testFile.path], () => {
+ testInitialized = true;
+ });
+ const transport = DevToolsServer.connectPipe();
+ const client = new DevToolsClient(transport);
+ await client.connect();
+
+ // Ensure that global actors are available. Just test the device actor.
+ const deviceFront = await client.mainRoot.getFront("device");
+ const desc = await deviceFront.getDescription();
+ equal(
+ desc.geckobuildid,
+ Services.appinfo.platformBuildID,
+ "device actor works"
+ );
+
+ // Even though we have no tabs, getMainProcess gives us the chrome debugger.
+ const targetDescriptor = await client.mainRoot.getMainProcess();
+ const front = await targetDescriptor.getTarget();
+ const watcher = await targetDescriptor.getWatcher();
+
+ const threadFront = await front.attachThread();
+
+ // Checks that the thread actor initializes immediately and that _setupDevToolsServer
+ // callback gets called.
+ ok(testInitialized);
+
+ const onPause = waitForPause(threadFront);
+
+ // Now load our test script,
+ // in another event loop so that the test can keep running!
+ Services.tm.dispatchToMainThread(() => {
+ load(testFile.path);
+ });
+
+ // and our "paused" listener should get hit.
+ info("Wait for first paused event");
+ const packet1 = await onPause;
+ equal(
+ packet1.why.type,
+ "breakpoint",
+ "yay - hit the breakpoint at the first line in our script"
+ );
+
+ // Resume again - next stop should be our "debugger" statement.
+ info("Wait for second pause event");
+ const packet2 = await resumeAndWaitForPause(threadFront);
+ equal(
+ packet2.why.type,
+ "debuggerStatement",
+ "yay - hit the 'debugger' statement in our script"
+ );
+
+ info("Dynamically add a breakpoint after the debugger statement");
+ const breakpointsFront = await watcher.getBreakpointListActor();
+ await breakpointsFront.setBreakpoint(
+ { sourceUrl: testFile.path, line: 11, column: 0 },
+ {}
+ );
+
+ // Resume again - next stop should be the new breakpoint.
+ info("Wait for third pause event");
+ const packet3 = await resumeAndWaitForPause(threadFront);
+ equal(
+ packet3.why.type,
+ "breakpoint",
+ "yay - hit the breakpoint added after starting the test"
+ );
+ finishClient(client);
+});
diff --git a/devtools/server/tests/xpcshell/testactors.js b/devtools/server/tests/xpcshell/testactors.js
new file mode 100644
index 0000000000..af208fe93e
--- /dev/null
+++ b/devtools/server/tests/xpcshell/testactors.js
@@ -0,0 +1,242 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ LazyPool,
+ createExtraActors,
+} = require("resource://devtools/shared/protocol/lazy-pool.js");
+const { RootActor } = require("resource://devtools/server/actors/root.js");
+const { ThreadActor } = require("resource://devtools/server/actors/thread.js");
+const {
+ DevToolsServer,
+} = require("resource://devtools/server/devtools-server.js");
+const {
+ ActorRegistry,
+} = require("resource://devtools/server/actors/utils/actor-registry.js");
+const {
+ SourcesManager,
+} = require("resource://devtools/server/actors/utils/sources-manager.js");
+const makeDebugger = require("resource://devtools/server/actors/utils/make-debugger.js");
+const protocol = require("resource://devtools/shared/protocol.js");
+const {
+ windowGlobalTargetSpec,
+} = require("resource://devtools/shared/specs/targets/window-global.js");
+const {
+ tabDescriptorSpec,
+} = require("resource://devtools/shared/specs/descriptors/tab.js");
+const Targets = require("resource://devtools/server/actors/targets/index.js");
+const {
+ createContentProcessSessionContext,
+} = require("resource://devtools/server/actors/watcher/session-context.js");
+
+var gTestGlobals = new Set();
+DevToolsServer.addTestGlobal = function (global) {
+ gTestGlobals.add(global);
+};
+DevToolsServer.removeTestGlobal = function (global) {
+ gTestGlobals.delete(global);
+};
+
+DevToolsServer.getTestGlobal = function (name) {
+ for (const g of gTestGlobals) {
+ if (g.__name == name) {
+ return g;
+ }
+ }
+
+ return null;
+};
+
+var gAllowNewThreadGlobals = false;
+DevToolsServer.allowNewThreadGlobals = function () {
+ gAllowNewThreadGlobals = true;
+};
+DevToolsServer.disallowNewThreadGlobals = function () {
+ gAllowNewThreadGlobals = false;
+};
+
+// A mock tab list, for use by tests. This simply presents each global in
+// gTestGlobals as a tab, and the list is fixed: it never calls its
+// onListChanged handler.
+//
+// As implemented now, we consult gTestGlobals when we're constructed, not
+// when we're iterated over, so tests have to add their globals before the
+// root actor is created.
+function TestTabList(connection) {
+ this.conn = connection;
+
+ // An array of actors for each global added with
+ // DevToolsServer.addTestGlobal.
+ this._descriptorActors = [];
+
+ // A pool mapping those actors' names to the actors.
+ this._descriptorActorPool = new LazyPool(connection);
+
+ for (const global of gTestGlobals) {
+ const actor = new TestTargetActor(connection, global);
+ this._descriptorActorPool.manage(actor);
+
+ const descriptorActor = new TestDescriptorActor(connection, actor);
+ this._descriptorActorPool.manage(descriptorActor);
+
+ this._descriptorActors.push(descriptorActor);
+ }
+}
+
+TestTabList.prototype = {
+ constructor: TestTabList,
+ destroy() {},
+ getList() {
+ return Promise.resolve([...this._descriptorActors]);
+ },
+ // Helper method only available for the xpcshell implementation of tablist.
+ getTargetActorForTab(title) {
+ const descriptorActor = this._descriptorActors.find(d => d.title === title);
+ if (!descriptorActor) {
+ return null;
+ }
+ return descriptorActor._targetActor;
+ },
+};
+
+exports.createRootActor = function createRootActor(connection) {
+ ActorRegistry.registerModule("devtools/server/actors/webconsole", {
+ prefix: "console",
+ constructor: "WebConsoleActor",
+ type: { target: true },
+ });
+ const root = new RootActor(connection, {
+ tabList: new TestTabList(connection),
+ globalActorFactories: ActorRegistry.globalActorFactories,
+ });
+
+ root.applicationType = "xpcshell-tests";
+ return root;
+};
+
+class TestDescriptorActor extends protocol.Actor {
+ constructor(conn, targetActor) {
+ super(conn, tabDescriptorSpec);
+ this._targetActor = targetActor;
+ }
+
+ // We don't exercise the selected tab in xpcshell tests.
+ get selected() {
+ return false;
+ }
+
+ get title() {
+ return this._targetActor.title;
+ }
+
+ form() {
+ const form = {
+ actor: this.actorID,
+ traits: {},
+ selected: this.selected,
+ title: this._targetActor.title,
+ url: this._targetActor.url,
+ };
+
+ return form;
+ }
+
+ getFavicon() {
+ return "";
+ }
+
+ getTarget() {
+ return this._targetActor.form();
+ }
+}
+
+class TestTargetActor extends protocol.Actor {
+ constructor(conn, global) {
+ super(conn, windowGlobalTargetSpec);
+
+ this.sessionContext = createContentProcessSessionContext();
+ this._global = global;
+ this._global.wrappedJSObject = global;
+ this.threadActor = new ThreadActor(this, this._global);
+ this.conn.addActor(this.threadActor);
+ this._extraActors = {};
+ // This is a hack in order to enable threadActor to be accessed from getFront
+ this._extraActors.threadActor = this.threadActor;
+ this.makeDebugger = makeDebugger.bind(null, {
+ findDebuggees: () => [this._global],
+ shouldAddNewGlobalAsDebuggee: g => gAllowNewThreadGlobals,
+ });
+ this.dbg = this.makeDebugger();
+ this.notifyResources = this.notifyResources.bind(this);
+ }
+
+ targetType = Targets.TYPES.FRAME;
+
+ get window() {
+ return this._global;
+ }
+
+ // Both title and url point to this._global.__name
+ get title() {
+ return this._global.__name;
+ }
+
+ get url() {
+ return this._global.__name;
+ }
+
+ get sourcesManager() {
+ if (!this._sourcesManager) {
+ this._sourcesManager = new SourcesManager(this.threadActor);
+ }
+ return this._sourcesManager;
+ }
+
+ form() {
+ const response = {
+ actor: this.actorID,
+ title: this.title,
+ threadActor: this.threadActor.actorID,
+ };
+
+ // Walk over target-scoped actors and add them to a new LazyPool.
+ const actorPool = new LazyPool(this.conn);
+ const actors = createExtraActors(
+ ActorRegistry.targetScopedActorFactories,
+ actorPool,
+ this
+ );
+ if (actorPool?._poolMap.size > 0) {
+ this._descriptorActorPool = actorPool;
+ this.conn.addActorPool(this._descriptorActorPool);
+ }
+
+ return { ...response, ...actors };
+ }
+
+ detach(request) {
+ this.threadActor.destroy();
+ return { type: "detached" };
+ }
+
+ reload(request) {
+ this.sourcesManager.reset();
+ this.threadActor.clearDebuggees();
+ this.threadActor.dbg.addDebuggees();
+ return {};
+ }
+
+ removeActorByName(name) {
+ const actor = this._extraActors[name];
+ if (this._descriptorActorPool) {
+ this._descriptorActorPool.removeActor(actor);
+ }
+ delete this._extraActors[name];
+ }
+
+ notifyResources(updateType, resources) {
+ this.emit(`resource-${updateType}-form`, resources);
+ }
+}
diff --git a/devtools/server/tests/xpcshell/webextension-helpers.js b/devtools/server/tests/xpcshell/webextension-helpers.js
new file mode 100644
index 0000000000..46968f09e7
--- /dev/null
+++ b/devtools/server/tests/xpcshell/webextension-helpers.js
@@ -0,0 +1,197 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* globals browser */
+
+"use strict";
+
+/**
+ * Test helpers shared by the devtools server xpcshell tests related to webextensions.
+ */
+
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+const {
+ CommandsFactory,
+} = require("resource://devtools/shared/commands/commands-factory.js");
+
+/**
+ * Loads and starts up a test extension given the provided extension configuration.
+ *
+ * @param {Object} extConfig - The extension configuration object
+ * @return {ExtensionWrapper} extension - Resolves with an extension object once the
+ * extension has started up.
+ */
+async function startupExtension(extConfig) {
+ const extension = ExtensionTestUtils.loadExtension(extConfig);
+
+ await extension.startup();
+
+ return extension;
+}
+exports.startupExtension = startupExtension;
+
+/**
+ * Initializes the extensionStorage actor for a given extension. This is effectively
+ * what happens when the addon storage panel is opened in the browser.
+ *
+ * @param {String} - id, The addon id
+ * @return {Object} - Resolves with the DevTools "commands" objact and the extensionStorage
+ * resource/front.
+ */
+async function openAddonStoragePanel(id) {
+ const commands = await CommandsFactory.forAddon(id);
+ await commands.targetCommand.startListening();
+
+ // Fetch the EXTENSION_STORAGE resource.
+ // Unfortunately, we can't use resourceCommand.waitForNextResource as it would destroy
+ // the actor by immediately unwatching for the resource type.
+ const extensionStorage = await new Promise(resolve => {
+ commands.resourceCommand.watchResources(
+ [commands.resourceCommand.TYPES.EXTENSION_STORAGE],
+ {
+ onAvailable(resources) {
+ resolve(resources[0]);
+ },
+ }
+ );
+ });
+
+ return { commands, extensionStorage };
+}
+exports.openAddonStoragePanel = openAddonStoragePanel;
+
+/**
+ * Builds the extension configuration object passed into ExtensionTestUtils.loadExtension
+ *
+ * @param {Object} options - Options, if any, to add to the configuration
+ * @param {Function} options.background - A function comprising the test extension's
+ * background script if provided
+ * @param {Object} options.files - An object whose keys correspond to file names and
+ * values map to the file contents
+ * @param {Object} options.manifest - An object representing the extension's manifest
+ * @return {Object} - The extension configuration object
+ */
+function getExtensionConfig(options = {}) {
+ const { manifest, ...otherOptions } = options;
+ const baseConfig = {
+ manifest: {
+ ...manifest,
+ permissions: ["storage"],
+ },
+ useAddonManager: "temporary",
+ };
+ return {
+ ...baseConfig,
+ ...otherOptions,
+ };
+}
+exports.getExtensionConfig = getExtensionConfig;
+
+/**
+ * Shared files for a test extension that has no background page but adds storage
+ * items via a transient extension page in a tab
+ */
+const ext_no_bg = {
+ files: {
+ "extension_page_in_tab.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>Extension Page in a Tab</h1>
+ <script src="extension_page_in_tab.js"></script>
+ </body>
+ </html>`,
+ "extension_page_in_tab.js": extensionScriptWithMessageListener,
+ },
+};
+exports.ext_no_bg = ext_no_bg;
+
+/**
+ * An extension script that can be used in any extension context (e.g. as a background
+ * script or as an extension page script loaded in a tab).
+ */
+async function extensionScriptWithMessageListener() {
+ let fireOnChanged = false;
+ browser.storage.onChanged.addListener(() => {
+ if (fireOnChanged) {
+ // Do not fire it again until explicitly requested again using the "storage-local-fireOnChanged" test message.
+ fireOnChanged = false;
+ browser.test.sendMessage("storage-local-onChanged");
+ }
+ });
+
+ browser.test.onMessage.addListener(async (msg, ...args) => {
+ let item = null;
+ switch (msg) {
+ case "storage-local-set":
+ await browser.storage.local.set(args[0]);
+ break;
+ case "storage-local-get":
+ item = await browser.storage.local.get(args[0]);
+ break;
+ case "storage-local-remove":
+ await browser.storage.local.remove(args[0]);
+ break;
+ case "storage-local-clear":
+ await browser.storage.local.clear();
+ break;
+ case "storage-local-fireOnChanged": {
+ // Allow the storage.onChanged listener to send a test event
+ // message when onChanged is being fired.
+ fireOnChanged = true;
+ // Do not fire fireOnChanged:done.
+ return;
+ }
+ default:
+ browser.test.fail(`Unexpected test message: ${msg}`);
+ }
+
+ browser.test.sendMessage(`${msg}:done`, item);
+ });
+ // window is available in background scripts
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage("extension-origin", window.location.origin);
+}
+exports.extensionScriptWithMessageListener = extensionScriptWithMessageListener;
+
+/**
+ * Shutdown procedure common to all tasks.
+ *
+ * @param {Object} extension - The test extension
+ * @param {Object} commands - The web extension commands used by the DevTools to interact with the backend
+ */
+async function shutdown(extension, commands) {
+ if (commands) {
+ await commands.destroy();
+ }
+ await extension.unload();
+}
+exports.shutdown = shutdown;
+
+/**
+ * Mocks the missing 'storage/permanent' directory needed by the "indexedDB"
+ * storage actor's 'populateStoresForHosts' method. This
+ * directory exists in a full browser i.e. mochitest.
+ */
+function createMissingIndexedDBDirs() {
+ const dir = Services.dirsvc.get("ProfD", Ci.nsIFile).clone();
+ dir.append("storage");
+ if (!dir.exists()) {
+ dir.create(dir.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ }
+ dir.append("permanent");
+ if (!dir.exists()) {
+ dir.create(dir.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ }
+
+ return dir;
+}
+exports.createMissingIndexedDBDirs = createMissingIndexedDBDirs;
diff --git a/devtools/server/tests/xpcshell/xpcshell.toml b/devtools/server/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..29ca414062
--- /dev/null
+++ b/devtools/server/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,436 @@
+[DEFAULT]
+tags = "devtools"
+head = "head_dbg.js"
+firefox-appdir = "browser"
+skip-if = ["os == 'android'"]
+# While not every devtools test uses evalInSandbox over 80 do, so it's easier to
+# set allow_parent_unrestricted_js_loads for all the tests here.
+# Similar story for the eval restrictions
+prefs = [
+ "security.allow_parent_unrestricted_js_loads=true",
+ "security.allow_eval_with_system_principal=true",
+ "security.allow_eval_in_parent_process=true",
+]
+
+support-files = [
+ "completions.js",
+ "webextension-helpers.js",
+ "source-map-data/sourcemapped.coffee",
+ "source-map-data/sourcemapped.map",
+ "post_init_global_actors.js",
+ "post_init_target_scoped_actors.js",
+ "pre_init_global_actors.js",
+ "pre_init_target_scoped_actors.js",
+ "registertestactors-lazy.js",
+ "sourcemapped.js",
+ "testactors.js",
+ "hello-actor.js",
+ "stepping.js",
+ "stepping-async.js",
+ "source-03.js",
+ "setBreakpoint-on-column.js",
+ "setBreakpoint-on-column-minified.js",
+ "setBreakpoint-on-column-in-gcd-script.js",
+ "setBreakpoint-on-column-with-no-offsets.js",
+ "setBreakpoint-on-column-with-no-offsets-in-gcd-script.js",
+ "setBreakpoint-on-line.js",
+ "setBreakpoint-on-line-in-gcd-script.js",
+ "setBreakpoint-on-line-with-multiple-offsets.js",
+ "setBreakpoint-on-line-with-multiple-statements.js",
+ "setBreakpoint-on-line-with-no-offsets.js",
+ "setBreakpoint-on-line-with-no-offsets-in-gcd-script.js",
+ "addons/web-extension/manifest.json",
+ "addons/web-extension-upgrade/manifest.json",
+ "addons/web-extension2/manifest.json",
+]
+
+["test_MemoryActor_saveHeapSnapshot_01.js"]
+
+["test_MemoryActor_saveHeapSnapshot_02.js"]
+
+["test_MemoryActor_saveHeapSnapshot_03.js"]
+
+["test_add_actors.js"]
+
+["test_addon_debugging_connect.js"]
+
+["test_addon_events.js"]
+
+["test_addon_reload.js"]
+
+["test_addons_actor.js"]
+
+["test_animation_name.js"]
+
+["test_animation_type.js"]
+
+["test_attach.js"]
+
+["test_blackboxing-01.js"]
+
+["test_blackboxing-02.js"]
+
+["test_blackboxing-03.js"]
+
+["test_blackboxing-04.js"]
+
+["test_blackboxing-05.js"]
+
+["test_blackboxing-08.js"]
+
+["test_breakpoint-01.js"]
+
+["test_breakpoint-03.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_breakpoint-04.js"]
+
+["test_breakpoint-05.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_breakpoint-06.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_breakpoint-07.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_breakpoint-08.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_breakpoint-09.js"]
+
+["test_breakpoint-10.js"]
+
+["test_breakpoint-11.js"]
+
+["test_breakpoint-12.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_breakpoint-13.js"]
+
+["test_breakpoint-14.js"]
+
+["test_breakpoint-16.js"]
+
+["test_breakpoint-17.js"]
+skip-if = ["true"] # tests for breakpoint actors are obsolete bug 1524374
+
+["test_breakpoint-18.js"]
+
+["test_breakpoint-19.js"]
+skip-if = ["true"] # bug 1104838
+
+["test_breakpoint-20.js"]
+
+["test_breakpoint-21.js"]
+
+["test_breakpoint-22.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_breakpoint-23.js"]
+
+["test_breakpoint-24.js"]
+
+["test_breakpoint-25.js"]
+
+["test_breakpoint-26.js"]
+
+["test_breakpoint-actor-map.js"]
+skip-if = ["true"] # tests for breakpoint actors are obsolete bug 1524374
+
+["test_client_request.js"]
+
+["test_conditional_breakpoint-01.js"]
+
+["test_conditional_breakpoint-02.js"]
+
+["test_conditional_breakpoint-03.js"]
+
+["test_conditional_breakpoint-04.js"]
+
+["test_connection_closes_all_pools.js"]
+
+["test_console_eval-01.js"]
+
+["test_console_eval-02.js"]
+
+["test_dbgactor.js"]
+
+["test_dbgclient_debuggerstatement.js"]
+
+["test_dbgglobal.js"]
+
+["test_extension_storage_actor.js"]
+skip-if = ["tsan"] # Unreasonably slow, bug 1612707
+
+["test_extension_storage_actor_upgrade.js"]
+
+["test_forwardingprefix.js"]
+
+["test_frameactor-01.js"]
+
+["test_frameactor-02.js"]
+
+["test_frameactor-03.js"]
+
+["test_frameactor-04.js"]
+
+["test_frameactor-05.js"]
+
+["test_frameactor_wasm-01.js"]
+
+["test_framearguments-01.js"]
+
+["test_framebindings-01.js"]
+
+["test_framebindings-02.js"]
+
+["test_framebindings-03.js"]
+
+["test_framebindings-04.js"]
+
+["test_framebindings-05.js"]
+
+["test_framebindings-06.js"]
+
+["test_framebindings-07.js"]
+
+["test_front_destroy.js"]
+
+["test_functiongrips-01.js"]
+
+["test_getRuleText.js"]
+
+["test_getTextAtLineColumn.js"]
+
+["test_get_command_and_arg.js"]
+
+["test_getyoungestframe.js"]
+
+["test_ignore_caught_exceptions.js"]
+
+["test_ignore_no_interface_exceptions.js"]
+
+["test_interrupt.js"]
+
+["test_layout-reflows-observer.js"]
+
+["test_listsources-01.js"]
+
+["test_listsources-02.js"]
+
+["test_listsources-03.js"]
+
+["test_logpoint-01.js"]
+
+["test_logpoint-02.js"]
+
+["test_logpoint-03.js"]
+
+["test_longstringgrips-01.js"]
+
+["test_nativewrappers.js"]
+
+["test_nesting-03.js"]
+
+["test_nesting-04.js"]
+
+["test_new_source-01.js"]
+
+["test_new_source-02.js"]
+
+["test_nodelistactor.js"]
+
+["test_objectgrips-02.js"]
+
+["test_objectgrips-03.js"]
+
+["test_objectgrips-04.js"]
+
+["test_objectgrips-05.js"]
+
+["test_objectgrips-06.js"]
+
+["test_objectgrips-07.js"]
+
+["test_objectgrips-08.js"]
+
+["test_objectgrips-14.js"]
+
+["test_objectgrips-15.js"]
+
+["test_objectgrips-16.js"]
+
+["test_objectgrips-17.js"]
+
+["test_objectgrips-18.js"]
+
+["test_objectgrips-19.js"]
+
+["test_objectgrips-20.js"]
+
+["test_objectgrips-21.js"]
+
+["test_objectgrips-22.js"]
+
+["test_objectgrips-23.js"]
+
+["test_objectgrips-24.js"]
+
+["test_objectgrips-25.js"]
+
+["test_objectgrips-fn-apply-01.js"]
+
+["test_objectgrips-fn-apply-02.js"]
+
+["test_objectgrips-fn-apply-03.js"]
+
+["test_objectgrips-nested-promise.js"]
+
+["test_objectgrips-nested-proxy.js"]
+
+["test_objectgrips-property-value-01.js"]
+
+["test_objectgrips-property-value-02.js"]
+
+["test_objectgrips-property-value-03.js"]
+
+["test_objectgrips-sparse-array.js"]
+
+["test_pause_exceptions-01.js"]
+
+["test_pause_exceptions-02.js"]
+
+["test_pause_exceptions-03.js"]
+
+["test_pause_exceptions-04.js"]
+
+["test_pauselifetime-01.js"]
+
+["test_pauselifetime-02.js"]
+
+["test_pauselifetime-03.js"]
+
+["test_pauselifetime-04.js"]
+
+["test_promise_state-01.js"]
+
+["test_promise_state-02.js"]
+
+["test_promise_state-03.js"]
+
+["test_register_actor.js"]
+
+["test_requestTypes.js"]
+
+["test_restartFrame-01.js"]
+
+["test_safe-getter.js"]
+
+["test_sessionDataHelpers.js"]
+
+["test_setBreakpoint-at-the-beginning-of-a-minified-fn.js"]
+
+["test_setBreakpoint-at-the-end-of-a-minified-fn.js"]
+
+["test_setBreakpoint-on-column-in-gcd-script.js"]
+
+["test_setBreakpoint-on-column.js"]
+
+["test_setBreakpoint-on-line-in-gcd-script.js"]
+
+["test_setBreakpoint-on-line-with-multiple-offsets.js"]
+
+["test_setBreakpoint-on-line-with-multiple-statements.js"]
+
+["test_setBreakpoint-on-line-with-no-offsets-in-gcd-script.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_setBreakpoint-on-line-with-no-offsets.js"]
+skip-if = ["true"] # breakpoint sliding is not supported bug 1525685
+
+["test_setBreakpoint-on-line.js"]
+
+["test_shapes_highlighter_helpers.js"]
+
+["test_source-01.js"]
+
+["test_source-02.js"]
+
+["test_source-03.js"]
+
+["test_source-04.js"]
+
+["test_stepping-01.js"]
+
+["test_stepping-02.js"]
+
+["test_stepping-03.js"]
+
+["test_stepping-04.js"]
+
+["test_stepping-05.js"]
+
+["test_stepping-06.js"]
+
+["test_stepping-07.js"]
+
+["test_stepping-08.js"]
+
+["test_stepping-09.js"]
+
+["test_stepping-10.js"]
+
+["test_stepping-11.js"]
+
+["test_stepping-12.js"]
+
+["test_stepping-13.js"]
+
+["test_stepping-14.js"]
+
+["test_stepping-15.js"]
+
+["test_stepping-16.js"]
+
+["test_stepping-17.js"]
+
+["test_stepping-18.js"]
+
+["test_stepping-19.js"]
+
+["test_stepping-with-skip-breakpoints.js"]
+
+["test_symbolactor.js"]
+
+["test_symbols-01.js"]
+
+["test_symbols-02.js"]
+
+["test_threadlifetime-01.js"]
+
+["test_threadlifetime-02.js"]
+
+["test_threadlifetime-04.js"]
+
+["test_unsafeDereference.js"]
+
+["test_wasm_source-01.js"]
+
+["test_watchpoint-01.js"]
+
+["test_watchpoint-02.js"]
+
+["test_watchpoint-03.js"]
+
+["test_watchpoint-04.js"]
+skip-if = ["apple_silicon"] # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs
+
+["test_watchpoint-05.js"]
+
+["test_webext_apis.js"]
+
+["test_webextension_descriptor.js"]
+
+["test_xpcshell_debugging.js"]
+support-files = ["xpcshell_debugging_script.js"]
diff --git a/devtools/server/tests/xpcshell/xpcshell_debugging_script.js b/devtools/server/tests/xpcshell/xpcshell_debugging_script.js
new file mode 100644
index 0000000000..f762b1c3e8
--- /dev/null
+++ b/devtools/server/tests/xpcshell/xpcshell_debugging_script.js
@@ -0,0 +1,11 @@
+dump("hello from the debugee!\n");
+// We should hit the above dump as we set a breakpoint on the first line
+
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This is a file that test_xpcshell_debugging.js debugs.
+
+debugger; // and why not check we hit this!?
+
+dump("try to set a breakpoint here");
diff --git a/devtools/server/tracer/moz.build b/devtools/server/tracer/moz.build
new file mode 100644
index 0000000000..26f7665018
--- /dev/null
+++ b/devtools/server/tracer/moz.build
@@ -0,0 +1,14 @@
+# -*- 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("tracer.jsm")
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"]
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"]
+
+with Files("**"):
+ BUG_COMPONENT = ("DevTools", "General")
diff --git a/devtools/server/tracer/tests/browser/Worker.tracer.js b/devtools/server/tracer/tests/browser/Worker.tracer.js
new file mode 100644
index 0000000000..60db4545a6
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/Worker.tracer.js
@@ -0,0 +1,10 @@
+"use strict";
+
+/* eslint-disable no-unused-vars */
+
+function bar() {}
+function foo() {
+ bar();
+}
+
+postMessage("evaled");
diff --git a/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js b/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js
new file mode 100644
index 0000000000..bd6e646b3b
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/WorkerDebugger.tracer.js
@@ -0,0 +1,36 @@
+"use strict";
+
+/* global global, loadSubScript */
+
+try {
+ // For some reason WorkerDebuggerGlobalScope.global doesn't expose JS variables
+ // and we can't call via global.foo(). Instead we have to go throught the Debugger API.
+ const dbg = new Debugger(global);
+ const [debuggee] = dbg.getDebuggees();
+
+ /* global startTracing, stopTracing, addTracingListener, removeTracingListener */
+ loadSubScript("resource://devtools/server/tracer/tracer.jsm");
+ const frames = [];
+ const listener = {
+ onTracingFrame(args) {
+ frames.push(args);
+
+ // Return true, to also log the trace to stdout
+ return true;
+ },
+ };
+ addTracingListener(listener);
+ startTracing({ global, prefix: "testWorkerPrefix" });
+
+ debuggee.executeInGlobal("foo()");
+
+ stopTracing();
+ removeTracingListener(listener);
+
+ // Send the frames to the main thread to do the assertions there.
+ postMessage(JSON.stringify(frames));
+} catch (e) {
+ dump(
+ "Exception while running debugger test script: " + e + "\n" + e.stack + "\n"
+ );
+}
diff --git a/devtools/server/tracer/tests/browser/browser.toml b/devtools/server/tracer/tests/browser/browser.toml
new file mode 100644
index 0000000000..61423b42b9
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/browser.toml
@@ -0,0 +1,11 @@
+[DEFAULT]
+tags = "devtools"
+subsuite = "devtools"
+
+["browser_document_tracer.js"]
+
+["browser_worker_tracer.js"]
+support-files = [
+ "Worker.tracer.js",
+ "WorkerDebugger.tracer.js",
+]
diff --git a/devtools/server/tracer/tests/browser/browser_document_tracer.js b/devtools/server/tracer/tests/browser/browser_document_tracer.js
new file mode 100644
index 0000000000..694842fa8b
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/browser_document_tracer.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const JS_CODE = `
+window.onclick = function foo() {
+ setTimeout(function bar() {
+ dump("click and timed out\n");
+ });
+};
+`;
+const TEST_URL =
+ "data:text/html,<!DOCTYPE html><html><script>" + JS_CODE + " </script>";
+
+add_task(async function testTracingWorker() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ const {
+ addTracingListener,
+ removeTracingListener,
+ startTracing,
+ stopTracing,
+ } = ChromeUtils.import("resource://devtools/server/tracer/tracer.jsm");
+
+ // We have to fake opening DevTools otherwise DebuggerNotificationObserver wouldn't work
+ // and the tracer wouldn't be able to trace the DOM events.
+ ChromeUtils.notifyDevToolsOpened();
+
+ const frames = [];
+ const listener = {
+ onTracingFrame(frameInfo) {
+ frames.push(frameInfo);
+ },
+ };
+ info("Register a tracing listener");
+ addTracingListener(listener);
+
+ info("Start tracing the iframe");
+ startTracing({ global: content, traceDOMEvents: true });
+
+ info("Dispatch a click event on the iframe");
+ EventUtils.synthesizeMouseAtCenter(
+ content.document.documentElement,
+ {},
+ content
+ );
+
+ info("Wait for the traces generated by this click");
+ await ContentTaskUtils.waitForCondition(() => frames.length == 2);
+
+ const firstFrame = frames[0];
+ is(firstFrame.formatedDisplayName, "λ foo");
+ is(firstFrame.currentDOMEvent, "DOM(click)");
+
+ const lastFrame = frames.at(-1);
+ is(lastFrame.formatedDisplayName, "λ bar");
+ is(lastFrame.currentDOMEvent, "setTimeoutCallback");
+
+ stopTracing();
+ removeTracingListener(listener);
+
+ ChromeUtils.notifyDevToolsClosed();
+ });
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/devtools/server/tracer/tests/browser/browser_worker_tracer.js b/devtools/server/tracer/tests/browser/browser_worker_tracer.js
new file mode 100644
index 0000000000..815da85853
--- /dev/null
+++ b/devtools/server/tracer/tests/browser/browser_worker_tracer.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const wdm = Cc["@mozilla.org/dom/workers/workerdebuggermanager;1"].getService(
+ Ci.nsIWorkerDebuggerManager
+);
+
+const BASE_URL =
+ "chrome://mochitests/content/browser/devtools/server/tracer/tests/browser/";
+const WORKER_URL = BASE_URL + "Worker.tracer.js";
+const WORKER_DEBUGGER_URL = BASE_URL + "WorkerDebugger.tracer.js";
+
+add_task(async function testTracingWorker() {
+ const onDbg = waitForWorkerDebugger(WORKER_URL);
+
+ info("Instantiate a regular worker");
+ const worker = new Worker(WORKER_URL);
+ info("Wait for worker to reply back");
+ await new Promise(r => (worker.onmessage = r));
+ info("Wait for WorkerDebugger to be instantiated");
+ const dbg = await onDbg;
+
+ const onDebuggerScriptSentFrames = new Promise(resolve => {
+ const listener = {
+ onMessage(msg) {
+ dbg.removeListener(listener);
+ resolve(JSON.parse(msg));
+ },
+ };
+ dbg.addListener(listener);
+ });
+ info("Evaluate a Worker Debugger test script");
+ dbg.initialize(WORKER_DEBUGGER_URL);
+
+ info("Wait for frames to be notified by the debugger script");
+ const frames = await onDebuggerScriptSentFrames;
+
+ is(frames.length, 3);
+ // There is a third frame which relates to the usage of Debugger.Object.executeInGlobal
+ // which we ignore as that's a test side effect.
+ const lastFrame = frames.at(-1);
+ const beforeLastFrame = frames.at(-2);
+ is(beforeLastFrame.depth, 1);
+ is(beforeLastFrame.formatedDisplayName, "λ foo");
+ is(beforeLastFrame.prefix, "testWorkerPrefix: ");
+ ok(beforeLastFrame.frame);
+ is(lastFrame.depth, 2);
+ is(lastFrame.formatedDisplayName, "λ bar");
+ is(lastFrame.prefix, "testWorkerPrefix: ");
+ ok(lastFrame.frame);
+});
+
+function waitForWorkerDebugger(url, dbgUrl) {
+ return new Promise(function (resolve) {
+ wdm.addListener({
+ onRegister(dbg) {
+ if (dbg.url !== url) {
+ return;
+ }
+ ok(true, "Debugger with url " + url + " should be registered.");
+ wdm.removeListener(this);
+ resolve(dbg);
+ },
+ });
+ });
+}
diff --git a/devtools/server/tracer/tests/xpcshell/test_tracer.js b/devtools/server/tracer/tests/xpcshell/test_tracer.js
new file mode 100644
index 0000000000..fe9a984aa8
--- /dev/null
+++ b/devtools/server/tracer/tests/xpcshell/test_tracer.js
@@ -0,0 +1,240 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { addTracingListener, removeTracingListener, startTracing, stopTracing } =
+ ChromeUtils.import("resource://devtools/server/tracer/tracer.jsm");
+
+add_task(async function () {
+ // Because this test uses evalInSandbox, we need to tweak the following prefs
+ Services.prefs.setBoolPref(
+ "security.allow_parent_unrestricted_js_loads",
+ true
+ );
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
+ });
+});
+
+add_task(async function testTracingContentGlobal() {
+ const toggles = [];
+ const frames = [];
+ const listener = {
+ onTracingToggled(state) {
+ toggles.push(state);
+ },
+ onTracingFrame(frameInfo) {
+ frames.push(frameInfo);
+ },
+ };
+
+ info("Register a tracing listener");
+ addTracingListener(listener);
+
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox("function bar() {}; function foo() {bar()};", sandbox);
+
+ info("Start tracing");
+ startTracing({ global: sandbox, prefix: "testContentPrefix" });
+ Assert.equal(toggles.length, 1);
+ Assert.equal(toggles[0], true);
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(frames.length, 2);
+ const lastFrame = frames.pop();
+ const beforeLastFrame = frames.pop();
+ Assert.equal(beforeLastFrame.depth, 0);
+ Assert.equal(beforeLastFrame.formatedDisplayName, "λ foo");
+ Assert.equal(beforeLastFrame.prefix, "testContentPrefix: ");
+ Assert.ok(beforeLastFrame.frame);
+ Assert.equal(lastFrame.depth, 1);
+ Assert.equal(lastFrame.formatedDisplayName, "λ bar");
+ Assert.equal(lastFrame.prefix, "testContentPrefix: ");
+ Assert.ok(lastFrame.frame);
+
+ info("Stop tracing");
+ stopTracing();
+ Assert.equal(toggles.length, 2);
+ Assert.equal(toggles[1], false);
+
+ info("Recall code after stop, no more traces are logged");
+ sandbox.foo();
+ Assert.equal(frames.length, 0);
+
+ info("Start tracing again, and recall code");
+ startTracing({ global: sandbox, prefix: "testContentPrefix" });
+ sandbox.foo();
+ info("New traces are logged");
+ Assert.equal(frames.length, 2);
+
+ info("Unregister the listener and recall code");
+ removeTracingListener(listener);
+ sandbox.foo();
+ info("No more traces are logged");
+ Assert.equal(frames.length, 2);
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingJSMGlobal() {
+ // We have to register the listener code in a sandbox, i.e. in a distinct global
+ // so that we aren't creating traces when tracer calls it. (and cause infinite loops)
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ const listenerSandbox = Cu.Sandbox(systemPrincipal);
+ Cu.evalInSandbox(
+ "new " +
+ function () {
+ globalThis.toggles = [];
+ globalThis.frames = [];
+ globalThis.listener = {
+ onTracingToggled(state) {
+ globalThis.toggles.push(state);
+ },
+ onTracingFrame(frameInfo) {
+ globalThis.frames.push(frameInfo);
+ },
+ };
+ },
+ listenerSandbox
+ );
+
+ info("Register a tracing listener");
+ addTracingListener(listenerSandbox.listener);
+
+ info("Start tracing");
+ startTracing({ global: null, prefix: "testPrefix" });
+ Assert.equal(listenerSandbox.toggles.length, 1);
+ Assert.equal(listenerSandbox.toggles[0], true);
+
+ info("Call some code");
+ function bar() {}
+ function foo() {
+ bar();
+ }
+ foo();
+
+ // Note that the tracer will record the two Assert.equal and the info calls.
+ // So only assert the last two frames.
+ const lastFrame = listenerSandbox.frames.at(-1);
+ const beforeLastFrame = listenerSandbox.frames.at(-2);
+ Assert.equal(beforeLastFrame.depth, 7);
+ Assert.equal(beforeLastFrame.formatedDisplayName, "λ foo");
+ Assert.equal(beforeLastFrame.prefix, "testPrefix: ");
+ Assert.ok(beforeLastFrame.frame);
+ Assert.equal(lastFrame.depth, 8);
+ Assert.equal(lastFrame.formatedDisplayName, "λ bar");
+ Assert.equal(lastFrame.prefix, "testPrefix: ");
+ Assert.ok(lastFrame.frame);
+
+ info("Stop tracing");
+ stopTracing();
+ Assert.equal(listenerSandbox.toggles.length, 2);
+ Assert.equal(listenerSandbox.toggles[1], false);
+
+ removeTracingListener(listenerSandbox.listener);
+});
+
+add_task(async function testTracingValues() {
+ // Test the `traceValues` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox(
+ `function foo() { bar(-0, 1, ["array"], { attribute: 3 }, "4", BigInt(5), Symbol("6"), Infinity, undefined, null, false, NaN, function foo() {}, function () {}, class MyClass {}); }; function bar(a, b, c) {}`,
+ sandbox
+ );
+
+ // Pass an override method to catch all strings tentatively logged to stdout
+ const logs = [];
+ function loggingMethod(str) {
+ logs.push(str);
+ }
+
+ info("Start tracing");
+ startTracing({ global: sandbox, traceValues: true, loggingMethod });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 3);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ foo()");
+ Assert.stringContains(
+ logs[2],
+ `λ bar(-0, 1, Array(1), [object Object], "4", BigInt(5), Symbol(6), Infinity, undefined, null, false, NaN, function foo(), function anonymous(), class MyClass)`
+ );
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingFunctionReturn() {
+ // Test the `traceFunctionReturn` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox(
+ `function foo() { bar(); return 0 } function bar() { return "string" }; foo();`,
+ sandbox
+ );
+
+ // Pass an override method to catch all strings tentatively logged to stdout
+ const logs = [];
+ function loggingMethod(str) {
+ logs.push(str);
+ }
+
+ info("Start tracing");
+ startTracing({ global: sandbox, traceFunctionReturn: true, loggingMethod });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 5);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ foo");
+ Assert.stringContains(logs[2], "λ bar");
+ Assert.stringContains(logs[3], `λ bar return`);
+ Assert.stringContains(logs[4], "λ foo return");
+
+ info("Stop tracing");
+ stopTracing();
+});
+
+add_task(async function testTracingFunctionReturnAndValues() {
+ // Test the `traceFunctionReturn` and `traceValues` flag
+ const sandbox = Cu.Sandbox("https://example.com");
+ Cu.evalInSandbox(
+ `function foo() { bar(); second(); } function bar() { return "string" }; function second() { return null; }; foo();`,
+ sandbox
+ );
+
+ // Pass an override method to catch all strings tentatively logged to stdout
+ const logs = [];
+ function loggingMethod(str) {
+ logs.push(str);
+ }
+
+ info("Start tracing");
+ startTracing({
+ global: sandbox,
+ traceFunctionReturn: true,
+ traceValues: true,
+ loggingMethod,
+ });
+
+ info("Call some code");
+ sandbox.foo();
+
+ Assert.equal(logs.length, 7);
+ Assert.equal(logs[0], "Start tracing JavaScript\n");
+ Assert.stringContains(logs[1], "λ foo()");
+ Assert.stringContains(logs[2], "λ bar()");
+ Assert.stringContains(logs[3], `λ bar return "string"`);
+ Assert.stringContains(logs[4], "λ second()");
+ Assert.stringContains(logs[5], `λ second return null`);
+ Assert.stringContains(logs[6], "λ foo return undefined");
+
+ info("Stop tracing");
+ stopTracing();
+});
diff --git a/devtools/server/tracer/tests/xpcshell/xpcshell.toml b/devtools/server/tracer/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..015fd2286c
--- /dev/null
+++ b/devtools/server/tracer/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,6 @@
+[DEFAULT]
+tags = "devtools"
+firefox-appdir = "browser"
+skip-if = ["os == 'android'"]
+
+["test_tracer.js"]
diff --git a/devtools/server/tracer/tracer.jsm b/devtools/server/tracer/tracer.jsm
new file mode 100644
index 0000000000..82c746bb57
--- /dev/null
+++ b/devtools/server/tracer/tracer.jsm
@@ -0,0 +1,798 @@
+/* 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/. */
+
+/**
+ * This module implements the JavaScript tracer.
+ *
+ * It is being used by:
+ * - any code that want to manually toggle the tracer, typically when debugging code,
+ * - the tracer actor to start and stop tracing from DevTools UI,
+ * - the tracing state resource watcher in order to notify DevTools UI about the tracing state.
+ *
+ * It will default logging the tracers to the terminal/stdout.
+ * But if DevTools are opened, it may delegate the logging to the tracer actor.
+ * It will typically log the traces to the Web Console.
+ *
+ * `JavaScriptTracer.onEnterFrame` method is hot codepath and should be reviewed accordingly.
+ */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "startTracing",
+ "stopTracing",
+ "addTracingListener",
+ "removeTracingListener",
+ "NEXT_INTERACTION_MESSAGE",
+];
+
+const NEXT_INTERACTION_MESSAGE =
+ "Waiting for next user interaction before tracing (next mousedown or keydown event)";
+
+const FRAME_EXIT_REASONS = {
+ // The function has been early terminated by the Debugger API
+ TERMINATED: "terminated",
+ // The function simply ends by returning a value
+ RETURN: "return",
+ // The function yields a new value
+ YIELD: "yield",
+ // The function await on a promise
+ AWAIT: "await",
+ // The function throws an exception
+ THROW: "throw",
+};
+
+const listeners = new Set();
+
+// This module can be loaded from the worker thread, where we can't use ChromeUtils.
+// So implement custom lazy getters (without XPCOMUtils ESM) from here.
+// Worker codepath in DevTools will pass a custom Debugger instance.
+const customLazy = {
+ get Debugger() {
+ // When this code runs in the worker thread, loaded via `loadSubScript`
+ // (ex: browser_worker_tracer.js and WorkerDebugger.tracer.js),
+ // this module runs within the WorkerDebuggerGlobalScope and have immediate access to Debugger class.
+ if (globalThis.Debugger) {
+ return globalThis.Debugger;
+ }
+ // When this code runs in the worker thread, loaded via `require`
+ // (ex: from tracer actor module),
+ // this module no longer has WorkerDebuggerGlobalScope as global,
+ // but has to use require() to pull Debugger.
+ if (typeof isWorker == "boolean") {
+ return require("Debugger");
+ }
+ const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+ );
+ // Avoid polluting all Modules global scope by using a Sandox as global.
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ const debuggerSandbox = Cu.Sandbox(systemPrincipal);
+ addDebuggerToGlobal(debuggerSandbox);
+ delete customLazy.Debugger;
+ customLazy.Debugger = debuggerSandbox.Debugger;
+ return customLazy.Debugger;
+ },
+
+ get DistinctCompartmentDebugger() {
+ const { addDebuggerToGlobal } = ChromeUtils.importESModule(
+ "resource://gre/modules/jsdebugger.sys.mjs"
+ );
+ const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ const debuggerSandbox = Cu.Sandbox(systemPrincipal, {
+ // As we may debug the JSM/ESM shared global, we should be using a Debugger
+ // from another system global.
+ freshCompartment: true,
+ });
+ addDebuggerToGlobal(debuggerSandbox);
+ delete customLazy.DistinctCompartmentDebugger;
+ customLazy.DistinctCompartmentDebugger = debuggerSandbox.Debugger;
+ return customLazy.DistinctCompartmentDebugger;
+ },
+};
+
+/**
+ * Start tracing against a given JS global.
+ * Only code run from that global will be logged.
+ *
+ * @param {Object} options
+ * Object with configurations:
+ * @param {Object} options.global
+ * The tracer only log traces related to the code executed within this global.
+ * When omitted, it will default to the options object's global.
+ * @param {String} options.prefix
+ * Optional string logged as a prefix to all traces.
+ * @param {Debugger} options.dbg
+ * Optional spidermonkey's Debugger instance.
+ * This allows devtools to pass a custom instance and ease worker support
+ * where we can't load jsdebugger.sys.mjs.
+ * @param {Boolean} options.loggingMethod
+ * Optional setting to use something else than `dump()` to log traces to stdout.
+ * This is mostly used by tests.
+ * @param {Boolean} options.traceDOMEvents
+ * Optional setting to enable tracing all the DOM events being going through
+ * dom/events/EventListenerManager.cpp's `EventListenerManager`.
+ * @param {Boolean} options.traceValues
+ * Optional setting to enable tracing all function call values as well,
+ * as returned values (when we do log returned frames).
+ * @param {Boolean} options.traceOnNextInteraction
+ * Optional setting to enable when the tracing should only start when the
+ * use starts interacting with the page. i.e. on next keydown or mousedown.
+ * @param {Boolean} options.traceFunctionReturn
+ * Optional setting to enable when the tracing should notify about frame exit.
+ * i.e. when a function call returns or throws.
+ * @param {Number} options.maxDepth
+ * Optional setting to ignore frames when depth is greater than the passed number.
+ * @param {Number} options.maxRecords
+ * Optional setting to stop the tracer after having recorded at least
+ * the passed number of top level frames.
+ */
+class JavaScriptTracer {
+ constructor(options) {
+ this.onEnterFrame = this.onEnterFrame.bind(this);
+
+ // By default, we would trace only JavaScript related to caller's global.
+ // As there is no way to compute the caller's global default to the global of the
+ // mandatory options argument.
+ this.tracedGlobal = options.global || Cu.getGlobalForObject(options);
+
+ // Instantiate a brand new Debugger API so that we can trace independently
+ // of all other DevTools operations. i.e. we can pause while tracing without any interference.
+ this.dbg = this.makeDebugger();
+
+ this.prefix = options.prefix ? `${options.prefix}: ` : "";
+
+ // List of all async frame which are poped per Spidermonkey API
+ // but are actually waiting for async operation.
+ // We should later enter them again when the async task they are being waiting for is completed.
+ this.pendingAwaitFrames = new Set();
+
+ this.loggingMethod = options.loggingMethod;
+ if (!this.loggingMethod) {
+ // On workers, `dump` can't be called with JavaScript on another object,
+ // so bind it.
+ // Detecting worker is different if this file is loaded via Common JS loader (isWorker)
+ // or as a JSM (constructor name)
+ this.loggingMethod =
+ typeof isWorker == "boolean" ||
+ globalThis.constructor.name == "WorkerDebuggerGlobalScope"
+ ? dump.bind(null)
+ : dump;
+ }
+
+ this.traceDOMEvents = !!options.traceDOMEvents;
+ this.traceValues = !!options.traceValues;
+ this.traceFunctionReturn = !!options.traceFunctionReturn;
+ this.maxDepth = options.maxDepth;
+ this.maxRecords = options.maxRecords;
+ this.records = 0;
+
+ // An increment used to identify function calls and their returned/exit frames
+ this.frameId = 0;
+
+ // This feature isn't supported on Workers as they aren't involving user events
+ if (options.traceOnNextInteraction && typeof isWorker !== "boolean") {
+ this.abortController = new AbortController();
+ const listener = () => {
+ this.abortController.abort();
+ // Avoid tracing if the users asked to stop tracing.
+ if (this.dbg) {
+ this.#startTracing();
+ }
+ };
+ const eventOptions = {
+ signal: this.abortController.signal,
+ capture: true,
+ };
+ // Register the event listener on the Chrome Event Handler in order to receive the event first.
+ // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler.
+ const eventHandler =
+ this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal;
+ eventHandler.addEventListener("mousedown", listener, eventOptions);
+ eventHandler.addEventListener("keydown", listener, eventOptions);
+
+ // Significate to the user that the tracer is registered, but not tracing just yet.
+ let shouldLogToStdout = listeners.size == 0;
+ for (const l of listeners) {
+ if (typeof l.onTracingPending == "function") {
+ shouldLogToStdout |= l.onTracingPending();
+ }
+ }
+ if (shouldLogToStdout) {
+ this.loggingMethod(this.prefix + NEXT_INTERACTION_MESSAGE + "\n");
+ }
+ } else {
+ this.#startTracing();
+ }
+ }
+
+ // Is actively tracing?
+ // We typically start tracing from the constructor, unless the "trace on next user interaction" feature is used.
+ isTracing = false;
+
+ /**
+ * Actually really start watching for executions.
+ *
+ * This may be delayed when traceOnNextInteraction options is used.
+ * Otherwise we start tracing as soon as the class instantiates.
+ */
+ #startTracing() {
+ this.isTracing = true;
+
+ this.dbg.onEnterFrame = this.onEnterFrame;
+
+ if (this.traceDOMEvents) {
+ this.startTracingDOMEvents();
+ }
+
+ // In any case, we consider the tracing as started
+ this.notifyToggle(true);
+ }
+
+ startTracingDOMEvents() {
+ this.debuggerNotificationObserver = new DebuggerNotificationObserver();
+ this.eventListener = this.eventListener.bind(this);
+ this.debuggerNotificationObserver.addListener(this.eventListener);
+ this.debuggerNotificationObserver.connect(this.tracedGlobal);
+
+ this.currentDOMEvent = null;
+ }
+
+ stopTracingDOMEvents() {
+ if (this.debuggerNotificationObserver) {
+ this.debuggerNotificationObserver.removeListener(this.eventListener);
+ this.debuggerNotificationObserver.disconnect(this.tracedGlobal);
+ this.debuggerNotificationObserver = null;
+ }
+ this.currentDOMEvent = null;
+ }
+
+ /**
+ * Called by DebuggerNotificationObserver interface when a DOM event start being notified
+ * and after it has been notified.
+ *
+ * @param {DebuggerNotification} notification
+ * Info about the DOM event. See the related idl file.
+ */
+ eventListener(notification) {
+ // For each event we get two notifications.
+ // One just before firing the listeners and another one just after.
+ //
+ // Update `this.currentDOMEvent` to be refering to the event name
+ // while the DOM event is being notified. It will be null the rest of the time.
+ //
+ // We don't need to maintain a stack of events as that's only consumed by onEnterFrame
+ // which only cares about the very lastest event being currently trigerring some code.
+ if (notification.phase == "pre") {
+ // We get notified about "real" DOM event, but also when some particular callbacks are called like setTimeout.
+ if (notification.type == "domEvent") {
+ let { type } = notification.event;
+ if (!type) {
+ // In the Worker thread, `notification.event` is an opaque wrapper.
+ // In other threads it is a Xray wrapper.
+ // Because of this difference, we have to fallback to use the Debugger.Object API.
+ type = this.dbg
+ .makeGlobalObjectReference(notification.global)
+ .makeDebuggeeValue(notification.event)
+ .getProperty("type").return;
+ }
+ this.currentDOMEvent = `DOM(${type})`;
+ } else {
+ this.currentDOMEvent = notification.type;
+ }
+ } else {
+ this.currentDOMEvent = null;
+ }
+ }
+
+ /**
+ * Stop observing execution.
+ *
+ * @param {String} reason
+ * Optional string to justify why the tracer stopped.
+ */
+ stopTracing(reason = "") {
+ // Note that this may be called before `#startTracing()`, but still want to completely shut it down.
+ if (!this.dbg) {
+ return;
+ }
+
+ this.dbg.onEnterFrame = undefined;
+ this.dbg.removeAllDebuggees();
+ this.dbg.onNewGlobalObject = undefined;
+ this.dbg = null;
+
+ this.depth = 0;
+
+ // Cancel the traceOnNextInteraction event listeners.
+ if (this.abortController) {
+ this.abortController.abort();
+ this.abortController = null;
+ }
+
+ if (this.traceDOMEvents) {
+ this.stopTracingDOMEvents();
+ }
+
+ this.tracedGlobal = null;
+ this.isTracing = false;
+
+ this.notifyToggle(false, reason);
+ }
+
+ /**
+ * Instantiate a Debugger API instance dedicated to each Tracer instance.
+ * It will notably be different from the instance used in DevTools.
+ * This allows to implement tracing independently of DevTools.
+ */
+ makeDebugger() {
+ // When this code runs in the worker thread, Cu isn't available
+ // and we don't have system principal anyway in this context.
+ const { isSystemPrincipal } =
+ typeof Cu == "object" ? Cu.getObjectPrincipal(this.tracedGlobal) : {};
+
+ // When debugging the system modules, we have to use a special instance
+ // of Debugger loaded in a distinct system global.
+ const dbg = isSystemPrincipal
+ ? new customLazy.DistinctCompartmentDebugger()
+ : new customLazy.Debugger();
+
+ // For now, we only trace calls for one particular global at a time.
+ // See the constructor for its definition.
+ dbg.addDebuggee(this.tracedGlobal);
+
+ return dbg;
+ }
+
+ /**
+ * Notify DevTools and/or the user via stdout that tracing
+ * has been enabled or disabled.
+ *
+ * @param {Boolean} state
+ * True if we just started tracing, false when it just stopped.
+ * @param {String} reason
+ * Optional string to justify why the tracer stopped.
+ */
+ notifyToggle(state, reason) {
+ let shouldLogToStdout = listeners.size == 0;
+ for (const listener of listeners) {
+ if (typeof listener.onTracingToggled == "function") {
+ shouldLogToStdout |= listener.onTracingToggled(state, reason);
+ }
+ }
+ if (shouldLogToStdout) {
+ if (state) {
+ this.loggingMethod(this.prefix + "Start tracing JavaScript\n");
+ } else {
+ if (reason) {
+ reason = ` (reason: ${reason})`;
+ }
+ this.loggingMethod(
+ this.prefix + "Stop tracing JavaScript" + reason + "\n"
+ );
+ }
+ }
+ }
+
+ /**
+ * Notify DevTools and/or the user via stdout that tracing
+ * stopped because of an infinite loop.
+ */
+ notifyInfiniteLoop() {
+ let shouldLogToStdout = listeners.size == 0;
+ for (const listener of listeners) {
+ if (typeof listener.onTracingInfiniteLoop == "function") {
+ shouldLogToStdout |= listener.onTracingInfiniteLoop();
+ }
+ }
+ if (shouldLogToStdout) {
+ this.loggingMethod(
+ this.prefix +
+ "Looks like an infinite recursion? We stopped the JavaScript tracer, but code may still be running!\n"
+ );
+ }
+ }
+
+ /**
+ * Called by the Debugger API (this.dbg) when a new frame is executed.
+ *
+ * @param {Debugger.Frame} frame
+ * A descriptor object for the JavaScript frame.
+ */
+ onEnterFrame(frame) {
+ // Safe check, just in case we keep being notified, but the tracer has been stopped
+ if (!this.dbg) {
+ return;
+ }
+ try {
+ // Because of async frame which are popped and entered again on completion of the awaited async task,
+ // we have to compute the depth from the frame. (and can't use a simple increment on enter/decrement on pop).
+ const depth = getFrameDepth(frame);
+
+ // Ignore the frame if we reached the depth limit (if one is provided)
+ if (this.maxDepth && depth >= this.maxDepth) {
+ return;
+ }
+
+ // When we encounter a frame which was previously popped because of pending on an async task,
+ // ignore it and only log the following ones.
+ if (this.pendingAwaitFrames.has(frame)) {
+ this.pendingAwaitFrames.delete(frame);
+ return;
+ }
+
+ // Auto-stop the tracer if we reached the number of max recorded top level frames
+ if (depth === 0 && this.maxRecords) {
+ if (this.records >= this.maxRecords) {
+ this.stopTracing("max-records");
+ return;
+ }
+ this.records++;
+ }
+
+ // Consider depth > 100 as an infinite recursive loop and stop the tracer.
+ if (depth == 100) {
+ this.notifyInfiniteLoop();
+ this.stopTracing("infinite-loop");
+ return;
+ }
+
+ const frameId = this.frameId++;
+ let shouldLogToStdout = true;
+
+ // If there is at least one DevTools debugging this process,
+ // delegate logging to DevTools actors.
+ if (listeners.size > 0) {
+ shouldLogToStdout = false;
+ const formatedDisplayName = formatDisplayName(frame);
+ for (const listener of listeners) {
+ // If any listener return true, also log to stdout
+ if (typeof listener.onTracingFrame == "function") {
+ shouldLogToStdout |= listener.onTracingFrame({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix: this.prefix,
+ currentDOMEvent: this.currentDOMEvent,
+ });
+ }
+ }
+ }
+
+ // DevTools may delegate the work to log to stdout,
+ // but if DevTools are closed, stdout is the only way to log the traces.
+ if (shouldLogToStdout) {
+ this.logFrameEnteredToStdout(frame, depth);
+ }
+
+ frame.onPop = completion => {
+ // Special case async frames. We are exiting the current frame because of waiting for an async task.
+ // (this is typically a `await foo()` from an async function)
+ // This frame should later be "entered" again.
+ if (completion?.await) {
+ this.pendingAwaitFrames.add(frame);
+ return;
+ }
+
+ if (!this.traceFunctionReturn) {
+ return;
+ }
+
+ let why = "";
+ let rv = undefined;
+ if (!completion) {
+ why = FRAME_EXIT_REASONS.TERMINATED;
+ } else if ("return" in completion) {
+ why = FRAME_EXIT_REASONS.RETURN;
+ rv = completion.return;
+ } else if ("yield" in completion) {
+ why = FRAME_EXIT_REASONS.YIELD;
+ rv = completion.yield;
+ } else if ("await" in completion) {
+ why = FRAME_EXIT_REASONS.AWAIT;
+ } else {
+ why = FRAME_EXIT_REASONS.THROW;
+ rv = completion.throw;
+ }
+
+ shouldLogToStdout = true;
+ if (listeners.size > 0) {
+ shouldLogToStdout = false;
+ const formatedDisplayName = formatDisplayName(frame);
+ for (const listener of listeners) {
+ // If any listener return true, also log to stdout
+ if (typeof listener.onTracingFrameExit == "function") {
+ shouldLogToStdout |= listener.onTracingFrameExit({
+ frameId,
+ frame,
+ depth,
+ formatedDisplayName,
+ prefix: this.prefix,
+ why,
+ rv,
+ });
+ }
+ }
+ }
+ if (shouldLogToStdout) {
+ this.logFrameExitedToStdout(frame, depth, why, rv);
+ }
+ };
+ } catch (e) {
+ console.error("Exception while tracing javascript", e);
+ }
+ }
+
+ /**
+ * Display to stdout one given frame execution, which represents a function call.
+ *
+ * @param {Debugger.Frame} frame
+ * @param {Number} depth
+ */
+ logFrameEnteredToStdout(frame, depth) {
+ const padding = "—".repeat(depth + 1);
+
+ // If we are tracing DOM events and we are in middle of an event,
+ // and are logging the topmost frame,
+ // then log a preliminary dedicated line to mention that event type.
+ if (this.currentDOMEvent && depth == 0) {
+ this.loggingMethod(this.prefix + padding + this.currentDOMEvent + "\n");
+ }
+
+ let message = `${padding}[${frame.implementation}]—> ${getTerminalHyperLink(
+ frame
+ )} - ${formatDisplayName(frame)}`;
+
+ // Log arguments, but only when this feature is enabled as it introduces
+ // some significant performance and visual overhead.
+ // Also prevent trying to log function call arguments if we aren't logging a frame
+ // with arguments (e.g. Debugger evaluation frames, when executing from the console)
+ if (this.traceValues && frame.arguments) {
+ message += "(";
+ for (let i = 0, l = frame.arguments.length; i < l; i++) {
+ const arg = frame.arguments[i];
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (arg?.unsafeDereference) {
+ // Special case classes as they can't be easily differentiated in pure JavaScript
+ if (arg.isClassConstructor) {
+ message += "class " + arg.name;
+ } else {
+ message += objectToString(arg.unsafeDereference());
+ }
+ } else {
+ message += primitiveToString(arg);
+ }
+
+ if (i < l - 1) {
+ message += ", ";
+ }
+ }
+ message += ")";
+ }
+
+ this.loggingMethod(this.prefix + message + "\n");
+ }
+
+ /**
+ * Display to stdout the exit of a given frame execution, which represents a function return.
+ *
+ * @param {Debugger.Frame} frame
+ * @param {String} why
+ * @param {Number} depth
+ */
+ logFrameExitedToStdout(frame, depth, why, rv) {
+ const padding = "—".repeat(depth + 1);
+
+ let message = `${padding}[${frame.implementation}]<— ${getTerminalHyperLink(
+ frame
+ )} - ${formatDisplayName(frame)} ${why}`;
+
+ // Log returned values, but only when this feature is enabled as it introduces
+ // some significant performance and visual overhead.
+ if (this.traceValues) {
+ message += " ";
+ // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
+ if (rv?.unsafeDereference) {
+ // Special case classes as they can't be easily differentiated in pure JavaScript
+ if (rv.isClassConstructor) {
+ message += "class " + rv.name;
+ } else {
+ message += objectToString(rv.unsafeDereference());
+ }
+ } else {
+ message += primitiveToString(rv);
+ }
+ }
+
+ this.loggingMethod(this.prefix + message + "\n");
+ }
+}
+
+/**
+ * Return a string description for any arbitrary JS value.
+ * Used when logging to stdout.
+ *
+ * @param {Object} obj
+ * Any JavaScript object to describe.
+ * @return String
+ * User meaningful descriptor for the object.
+ */
+function objectToString(obj) {
+ if (Element.isInstance(obj)) {
+ let message = `<${obj.tagName}`;
+ if (obj.id) {
+ message += ` #${obj.id}`;
+ }
+ if (obj.className) {
+ message += ` .${obj.className}`;
+ }
+ message += ">";
+ return message;
+ } else if (Array.isArray(obj)) {
+ return `Array(${obj.length})`;
+ } else if (Event.isInstance(obj)) {
+ return `Event(${obj.type}) target=${objectToString(obj.target)}`;
+ } else if (typeof obj === "function") {
+ return `function ${obj.name || "anonymous"}()`;
+ }
+ return obj;
+}
+
+function primitiveToString(value) {
+ const type = typeof value;
+ if (type === "string") {
+ // Use stringify to escape special characters and display in enclosing quotes.
+ return JSON.stringify(value);
+ } else if (value === 0 && 1 / value === -Infinity) {
+ // -0 is very special and need special threatment.
+ return "-0";
+ } else if (type === "bigint") {
+ return `BigInt(${value})`;
+ } else if (value && typeof value.toString === "function") {
+ // Use toString as it allows to stringify Symbols. Converting them to string throws.
+ return value.toString();
+ }
+
+ // For all other types/cases, rely on native convertion to string
+ return value;
+}
+
+/**
+ * Try to describe the current frame we are tracing
+ *
+ * This will typically log the name of the method being called.
+ *
+ * @param {Debugger.Frame} frame
+ * The frame which is currently being executed.
+ */
+function formatDisplayName(frame) {
+ if (frame.type === "call") {
+ const callee = frame.callee;
+ // Anonymous function will have undefined name and displayName.
+ return "λ " + (callee.name || callee.displayName || "anonymous");
+ }
+
+ return `(${frame.type})`;
+}
+
+let activeTracer = null;
+
+/**
+ * Start tracing JavaScript.
+ * i.e. log the name of any function being called in JS and its location in source code.
+ *
+ * @params {Object} options (mandatory)
+ * See JavaScriptTracer.startTracing jsdoc.
+ */
+function startTracing(options) {
+ if (!options) {
+ throw new Error("startTracing excepts an options object as first argument");
+ }
+ if (!activeTracer) {
+ activeTracer = new JavaScriptTracer(options);
+ } else {
+ console.warn(
+ "Can't start JavaScript tracing, another tracer is still active and we only support one tracer at a time."
+ );
+ }
+}
+
+/**
+ * Stop tracing JavaScript.
+ */
+function stopTracing() {
+ if (activeTracer) {
+ activeTracer.stopTracing();
+ activeTracer = null;
+ } else {
+ console.warn("Can't stop JavaScript Tracing as we were not tracing.");
+ }
+}
+
+/**
+ * Listen for tracing updates.
+ *
+ * The listener object may expose the following methods:
+ * - onTracingToggled(state)
+ * Where state is a boolean to indicate if tracing has just been enabled of disabled.
+ * It may be immediatelly called if a tracer is already active.
+ *
+ * - onTracingInfiniteLoop()
+ * Called when the tracer stopped because of an infinite loop.
+ *
+ * - onTracingFrame({ frame, depth, formatedDisplayName, prefix })
+ * Called each time we enter a new JS frame.
+ * - frame is a Debugger.Frame object
+ * - depth is a number and represents the depth of the frame in the call stack
+ * - formatedDisplayName is a string and is a human readable name for the current frame
+ * - prefix is a string to display as a prefix of any logged frame
+ *
+ * @param {Object} listener
+ */
+function addTracingListener(listener) {
+ listeners.add(listener);
+
+ if (
+ activeTracer?.isTracing &&
+ typeof listener.onTracingToggled == "function"
+ ) {
+ listener.onTracingToggled(true);
+ }
+}
+
+/**
+ * Unregister a listener previous registered via addTracingListener
+ */
+function removeTracingListener(listener) {
+ listeners.delete(listener);
+}
+
+function getFrameDepth(frame) {
+ if (typeof frame.depth !== "number") {
+ let depth = 0;
+ let f = frame;
+ while ((f = f.older)) {
+ depth++;
+ }
+ frame.depth = depth;
+ }
+
+ return frame.depth;
+}
+
+/**
+ * Generate a magic string that will be rendered in smart terminals as a URL
+ * for the given Frame object. This URL is special as it includes a line and column.
+ * This URL can be clicked and Firefox will automatically open the source matching
+ * the frame's URL in the currently opened Debugger.
+ * Firefox will interpret differently the URLs ending with `/:?\d*:\d+/`.
+ *
+ * @param {Debugger.Frame} frame
+ * The frame being traced.
+ * @return {String}
+ * The URL's magic string.
+ */
+function getTerminalHyperLink(frame) {
+ const { script } = frame;
+ const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
+
+ // Use a special URL, including line and column numbers which Firefox
+ // interprets as to be opened in the already opened DevTool's debugger
+ const href = `${script.source.url}:${lineNumber}:${columnNumber}`;
+
+ // Use special characters in order to print working hyperlinks right from the terminal
+ // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
+ return `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`;
+}
+
+// This JSM may be execute as CommonJS when loaded in the worker thread
+if (typeof module == "object") {
+ module.exports = {
+ startTracing,
+ stopTracing,
+ addTracingListener,
+ removeTracingListener,
+ };
+}