diff options
Diffstat (limited to 'devtools/server')
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 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 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 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 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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </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"> </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 Binary files differnew file mode 100644 index 0000000000..bda383e594 --- /dev/null +++ b/devtools/server/tests/chrome/large-image.jpg 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 Binary files differnew file mode 100644 index 0000000000..e702427a53 --- /dev/null +++ b/devtools/server/tests/chrome/small-image.gif 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, + }; +} |