/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ /* exported HighlighterTestActor, HighlighterTestFront */ "use strict"; // A helper actor for testing highlighters. // ⚠️ This should only be used for getting data for objects using CanvasFrameAnonymousContentHelper, // that we can't get directly from tests. const { getRect, getAdjustedQuads, } = require("resource://devtools/shared/layout/utils.js"); // Set up a dummy environment so that EventUtils works. We need to be careful to // pass a window object into each EventUtils method we call rather than having // it rely on the |window| global. const EventUtils = {}; EventUtils.window = {}; EventUtils.parent = {}; /* eslint-disable camelcase */ EventUtils._EU_Ci = Ci; EventUtils._EU_Cc = Cc; /* eslint-disable camelcase */ Services.scriptloader.loadSubScript( "chrome://mochikit/content/tests/SimpleTest/EventUtils.js", EventUtils ); // We're an actor so we don't run in the browser test environment, so // we need to import TestUtils manually despite what the linter thinks. // eslint-disable-next-line mozilla/no-redeclare-with-import-autofix const { TestUtils } = ChromeUtils.importESModule( "resource://testing-common/TestUtils.sys.mjs" ); const protocol = require("resource://devtools/shared/protocol.js"); const { Arg, RetVal } = protocol; const dumpn = msg => { dump(msg + "\n"); }; /** * Get the instance of CanvasFrameAnonymousContentHelper used by a given * highlighter actor. * The instance provides methods to get/set attributes/text/style on nodes of * the highlighter, inserted into the nsCanvasFrame. * @see /devtools/server/actors/highlighters.js * @param {String} actorID */ function getHighlighterCanvasFrameHelper(conn, actorID) { // Retrieve the CustomHighlighterActor by its actorID: const actor = conn.getActor(actorID); if (!actor) { return null; } // Retrieve the sub class instance specific to each highlighter type: let highlighter = actor.instance; // SelectorHighlighter and TabbingOrderHighlighter can hold multiple highlighters. // For now, only retrieve the first highlighter. if ( highlighter._highlighters && Array.isArray(highlighter._highlighters) && highlighter._highlighters.length ) { highlighter = highlighter._highlighters[0]; } // Now, `highlighter` should be a final highlighter class, exposing // `CanvasFrameAnonymousContentHelper` via a `markup` attribute. if (highlighter.markup) { return highlighter.markup; } // Here we didn't find any highlighter; it can happen if the actor is a // FontsHighlighter (which does not use a CanvasFrameAnonymousContentHelper). return null; } var highlighterTestSpec = protocol.generateActorSpec({ typeName: "highlighterTest", events: { "highlighter-updated": {}, }, methods: { getHighlighterAttribute: { request: { nodeID: Arg(0, "string"), name: Arg(1, "string"), actorID: Arg(2, "string"), }, response: { value: RetVal("string"), }, }, getHighlighterBoundingClientRect: { request: { nodeID: Arg(0, "string"), actorID: Arg(1, "string"), }, response: { value: RetVal("json"), }, }, getHighlighterComputedStyle: { request: { nodeID: Arg(0, "string"), property: Arg(1, "string"), actorID: Arg(2, "string"), }, response: { value: RetVal("string"), }, }, getHighlighterNodeTextContent: { request: { nodeID: Arg(0, "string"), actorID: Arg(1, "string"), }, response: { value: RetVal("string"), }, }, getSelectorHighlighterBoxNb: { request: { highlighter: Arg(0, "string"), }, response: { value: RetVal("number"), }, }, changeHighlightedNodeWaitForUpdate: { request: { name: Arg(0, "string"), value: Arg(1, "string"), actorID: Arg(2, "string"), }, response: {}, }, registerOneTimeHighlighterUpdate: { request: { actorID: Arg(0, "string"), }, response: {}, }, getNodeRect: { request: { selector: Arg(0, "string"), }, response: { value: RetVal("json"), }, }, getTextNodeRect: { request: { parentSelector: Arg(0, "string"), childNodeIndex: Arg(1, "number"), }, response: { value: RetVal("json"), }, }, isPausedDebuggerOverlayVisible: { request: {}, response: { value: RetVal("boolean"), }, }, clickPausedDebuggerOverlayButton: { request: { id: Arg(0, "string"), }, response: {}, }, isEyeDropperVisible: { request: {}, response: { value: RetVal("boolean"), }, }, getEyeDropperElementAttribute: { request: { elementId: Arg(0, "string"), attributeName: Arg(1, "string"), }, response: { value: RetVal("string"), }, }, getEyeDropperColorValue: { request: {}, response: { value: RetVal("string"), }, }, getTabbingOrderHighlighterData: { request: {}, response: { value: RetVal("json"), }, }, }, }); class HighlighterTestActor extends protocol.Actor { constructor(conn, targetActor) { super(conn, highlighterTestSpec); this.targetActor = targetActor; } get content() { return this.targetActor.window; } /** * Helper to retrieve a DOM element. * @param {string | array} selector Either a regular selector string * or a selector array. If an array, each item, except the last one * are considered matching an iframe, so that we can query element * within deep iframes. */ _querySelector(selector) { let document = this.content.document; if (Array.isArray(selector)) { const fullSelector = selector.join(" >> "); while (selector.length > 1) { const str = selector.shift(); const iframe = document.querySelector(str); if (!iframe) { throw new Error( 'Unable to find element with selector "' + str + '"' + " (full selector:" + fullSelector + ")" ); } if (!iframe.contentWindow) { throw new Error( "Iframe selector doesn't target an iframe \"" + str + '"' + " (full selector:" + fullSelector + ")" ); } document = iframe.contentWindow.document; } selector = selector.shift(); } const node = document.querySelector(selector); if (!node) { throw new Error( 'Unable to find element with selector "' + selector + '"' ); } return node; } /** * Get a value for a given attribute name, on one of the elements of the box * model highlighter, given its ID. * @param {String} nodeID The full ID of the element to get the attribute for * @param {String} name The name of the attribute to get * @param {String} actorID The highlighter actor ID * @return {String} The value, if found, null otherwise */ getHighlighterAttribute(nodeID, name, actorID) { const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); if (!helper) { throw new Error(`Highlighter not found`); } return helper.getAttributeForElement(nodeID, name); } /** * Get the bounding client rect for an highlighter element, given its ID. * * @param {String} nodeID The full ID of the element to get the DOMRect for * @param {String} actorID The highlighter actor ID * @return {DOMRect} The value, if found, null otherwise */ getHighlighterBoundingClientRect(nodeID, actorID) { const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); if (!helper) { throw new Error(`Highlighter not found`); } return helper.getBoundingClientRect(nodeID); } /** * Get the computed style for a given property, on one of the elements of the * box model highlighter, given its ID. * @param {String} nodeID The full ID of the element to get the attribute for * @param {String} property The name of the property * @param {String} actorID The highlighter actor ID * @return {String} The computed style of the property */ getHighlighterComputedStyle(nodeID, property, actorID) { const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); if (!helper) { throw new Error(`Highlighter not found`); } return helper.getElement(nodeID).computedStyle.getPropertyValue(property); } /** * Get the textcontent of one of the elements of the box model highlighter, * given its ID. * @param {String} nodeID The full ID of the element to get the attribute for * @param {String} actorID The highlighter actor ID * @return {String} The textcontent value */ getHighlighterNodeTextContent(nodeID, actorID) { let value; const helper = getHighlighterCanvasFrameHelper(this.conn, actorID); if (helper) { value = helper.getTextContentForElement(nodeID); } return value; } /** * Get the number of box-model highlighters created by the SelectorHighlighter * @param {String} actorID The highlighter actor ID * @return {Number} The number of box-model highlighters created, or null if the * SelectorHighlighter was not found. */ getSelectorHighlighterBoxNb(actorID) { const highlighter = this.conn.getActor(actorID); const { _highlighter: h } = highlighter; if (!h || !h._highlighters) { return null; } return h._highlighters.length; } /** * Subscribe to the box-model highlighter's update event, modify an attribute of * the currently highlighted node and send a message when the highlighter has * updated. * @param {String} the name of the attribute to be changed * @param {String} the new value for the attribute * @param {String} actorID The highlighter actor ID */ changeHighlightedNodeWaitForUpdate(name, value, actorID) { return new Promise(resolve => { const highlighter = this.conn.getActor(actorID); const { _highlighter: h } = highlighter; h.once("updated", resolve); h.currentNode.setAttribute(name, value); }); } /** * Register a one-time "updated" event listener. * The method does not wait for the "updated" event itself so the response can be sent * back and the client would know the event listener is properly set. * A separate event, "highlighter-updated", will be emitted when the highlighter updates. * * @param {String} actorID The highlighter actor ID */ registerOneTimeHighlighterUpdate(actorID) { const { _highlighter } = this.conn.getActor(actorID); _highlighter.once("updated").then(() => this.emit("highlighter-updated")); // Return directly so the client knows the event listener is set } async getNodeRect(selector) { const node = this._querySelector(selector); return getRect(this.content, node, this.content); } async getTextNodeRect(parentSelector, childNodeIndex) { const parentNode = this._querySelector(parentSelector); const node = parentNode.childNodes[childNodeIndex]; return getAdjustedQuads(this.content, node)[0].bounds; } /** * @returns {PausedDebuggerOverlay} The paused overlay instance */ _getPausedDebuggerOverlay() { // We use `_pauseOverlay` since it's the cached value; `pauseOverlay` is a getter that // will create the overlay when called (if it does not exist yet). return this.targetActor?.threadActor?._pauseOverlay; } isPausedDebuggerOverlayVisible() { const pauseOverlay = this._getPausedDebuggerOverlay(); if (!pauseOverlay) { return false; } const root = pauseOverlay.getElement("root"); const toolbar = pauseOverlay.getElement("toolbar"); return ( root.getAttribute("hidden") !== "true" && root.getAttribute("overlay") == "true" && toolbar.getAttribute("hidden") !== "true" && !!toolbar.getTextContent() ); } /** * Simulates a click on a button of the debugger pause overlay. * * @param {String} id: The id of the element (e.g. "paused-dbg-resume-button"). */ async clickPausedDebuggerOverlayButton(id) { const pauseOverlay = this._getPausedDebuggerOverlay(); if (!pauseOverlay) { return; } // Because the highlighter markup elements live inside an anonymous content frame which // does not expose an API to dispatch events to them, we can't directly dispatch // events to the nodes themselves. // We're directly calling `handleEvent` on the pause overlay, which is the mouse events // listener callback on the overlay. pauseOverlay.handleEvent({ type: "mousedown", target: { id } }); } /** * @returns {EyeDropper} */ _getEyeDropper() { const form = this.targetActor.form(); const inspectorActor = this.conn._getOrCreateActor(form.inspectorActor); return inspectorActor?._eyeDropper; } isEyeDropperVisible() { const eyeDropper = this._getEyeDropper(); if (!eyeDropper) { return false; } return eyeDropper.getElement("root").getAttribute("hidden") !== "true"; } getEyeDropperElementAttribute(elementId, attributeName) { const eyeDropper = this._getEyeDropper(); if (!eyeDropper) { return null; } return eyeDropper.getElement(elementId).getAttribute(attributeName); } async getEyeDropperColorValue() { const eyeDropper = this._getEyeDropper(); if (!eyeDropper) { return null; } // It might happen that while the eyedropper isn't hidden anymore, the color-value // is not set yet. const color = await TestUtils.waitForCondition(() => { const colorValueElement = eyeDropper.getElement("color-value"); const textContent = colorValueElement.getTextContent(); return textContent; }, "Couldn't get a non-empty text content for the color-value element"); return color; } /** * Get the TabbingOrderHighlighter for the associated targetActor * * @returns {TabbingOrderHighlighter} */ _getTabbingOrderHighlighter() { const form = this.targetActor.form(); const accessibilityActor = this.conn._getOrCreateActor( form.accessibilityActor ); if (!accessibilityActor) { return null; } // We use `_tabbingOrderHighlighter` since it's the cached value; `tabbingOrderHighlighter` // is a getter that will create the highlighter when called (if it does not exist yet). return accessibilityActor.walker?._tabbingOrderHighlighter; } /** * Get a representation of the NodeTabbingOrderHighlighters created by the * TabbingOrderHighlighter of a given targetActor. * * @returns {Array} An array which will contain as many entry as they are * NodeTabbingOrderHighlighters displayed. * Each item will be of the form `nodename[#id]: index`. * For example: * [ * `button#top-btn-1 : 1`, * `html : 2`, * `button#iframe-btn-1 : 3`, * `button#iframe-btn-2 : 4`, * `button#top-btn-2 : 5`, * ] */ getTabbingOrderHighlighterData() { const highlighter = this._getTabbingOrderHighlighter(); if (!highlighter) { return []; } const nodeTabbingOrderHighlighters = [ ...highlighter._highlighter._highlighters.values(), ].filter(h => h.getElement("root").getAttribute("hidden") !== "true"); return nodeTabbingOrderHighlighters.map(h => { let nodeStr = h.currentNode.nodeName.toLowerCase(); if (h.currentNode.id) { nodeStr = `${nodeStr}#${h.currentNode.id}`; } return `${nodeStr} : ${h.getElement("root").getTextContent()}`; }); } } exports.HighlighterTestActor = HighlighterTestActor; class HighlighterTestFront extends protocol.FrontClassWithSpec( highlighterTestSpec ) { constructor(client, targetFront, parentFront) { super(client, targetFront, parentFront); this.formAttributeName = "highlighterTestActor"; // The currently active highlighter is obtained by calling a custom getter // provided manually after requesting TestFront. See `getHighlighterTestFront(toolbox)` this._highlighter = null; } /** * Override the highlighter getter with a custom method that returns * the currently active highlighter instance. * * @param {Function|Highlighter} _customHighlighterGetter */ set highlighter(_customHighlighterGetter) { this._highlighter = _customHighlighterGetter; } /** * The currently active highlighter instance. * If there is a custom getter for the highlighter, return its result. * * @return {Highlighter|null} */ get highlighter() { return typeof this._highlighter === "function" ? this._highlighter() : this._highlighter; } /* eslint-disable max-len */ changeHighlightedNodeWaitForUpdate(name, value, highlighter) { /* eslint-enable max-len */ return super.changeHighlightedNodeWaitForUpdate( name, value, (highlighter || this.highlighter).actorID ); } /** * Get the value of an attribute on one of the highlighter's node. * @param {String} nodeID The Id of the node in the highlighter. * @param {String} name The name of the attribute. * @param {Object} highlighter Optional custom highlighter to target * @return {String} value */ getHighlighterNodeAttribute(nodeID, name, highlighter) { return this.getHighlighterAttribute( nodeID, name, (highlighter || this.highlighter).actorID ); } getHighlighterNodeTextContent(nodeID, highlighter) { return super.getHighlighterNodeTextContent( nodeID, (highlighter || this.highlighter).actorID ); } /** * Get the computed style of a property on one of the highlighter's node. * @param {String} nodeID The Id of the node in the highlighter. * @param {String} property The name of the property. * @param {Object} highlighter Optional custom highlighter to target * @return {String} value */ getHighlighterComputedStyle(nodeID, property, highlighter) { return super.getHighlighterComputedStyle( nodeID, property, (highlighter || this.highlighter).actorID ); } /** * Is the highlighter currently visible on the page? */ async isHighlighting() { // Once the highlighter is hidden, the reference to it is lost. // Assume it is not highlighting. if (!this.highlighter) { return false; } try { const hidden = await this.getHighlighterNodeAttribute( "box-model-elements", "hidden" ); return hidden === null; } catch (e) { if (e.message.match(/Highlighter not found/)) { return false; } throw e; } } /** * Get the current rect of the border region of the box-model highlighter */ async getSimpleBorderRect() { const { border } = await this.getBoxModelStatus(); const { p1, p2, p4 } = border.points; return { top: p1.y, left: p1.x, width: p2.x - p1.x, height: p4.y - p1.y, }; } /** * Get the current positions and visibility of the various box-model highlighter * elements. */ async getBoxModelStatus() { const isVisible = await this.isHighlighting(); const ret = { visible: isVisible, }; for (const region of ["margin", "border", "padding", "content"]) { const points = await this._getPointsForRegion(region); const visible = await this._isRegionHidden(region); ret[region] = { points, visible }; } ret.guides = {}; for (const guide of ["top", "right", "bottom", "left"]) { ret.guides[guide] = await this._getGuideStatus(guide); } return ret; } /** * Check that the box-model highlighter is currently highlighting the node matching the * given selector. * @param {String} selector * @return {Boolean} */ async assertHighlightedNode(selector) { const rect = await this.getNodeRect(selector); return this.isNodeRectHighlighted(rect); } /** * Check that the box-model highlighter is currently highlighting the text node that can * be found at a given index within the list of childNodes of a parent element matching * the given selector. * @param {String} parentSelector * @param {Number} childNodeIndex * @return {Boolean} */ async assertHighlightedTextNode(parentSelector, childNodeIndex) { const rect = await this.getTextNodeRect(parentSelector, childNodeIndex); return this.isNodeRectHighlighted(rect); } /** * Check that the box-model highlighter is currently highlighting the given rect. * @param {Object} rect * @return {Boolean} */ async isNodeRectHighlighted({ left, top, width, height }) { const { visible, border } = await this.getBoxModelStatus(); let points = border.points; if (!visible) { return false; } // Check that the node is within the box model const right = left + width; const bottom = top + height; // Converts points dictionnary into an array const list = []; for (let i = 1; i <= 4; i++) { const p = points["p" + i]; list.push([p.x, p.y]); } points = list; // Check that each point of the node is within the box model return ( isInside([left, top], points) && isInside([right, top], points) && isInside([right, bottom], points) && isInside([left, bottom], points) ); } /** * Get the coordinate (points attribute) from one of the polygon elements in the * box model highlighter. */ async _getPointsForRegion(region) { const d = await this.getHighlighterNodeAttribute( "box-model-" + region, "d" ); if (!d) { return null; } const polygons = d.match(/M[^M]+/g); if (!polygons) { return null; } const points = polygons[0] .trim() .split(" ") .map(i => { return i.replace(/M|L/, "").split(","); }); return { p1: { x: parseFloat(points[0][0]), y: parseFloat(points[0][1]), }, p2: { x: parseFloat(points[1][0]), y: parseFloat(points[1][1]), }, p3: { x: parseFloat(points[2][0]), y: parseFloat(points[2][1]), }, p4: { x: parseFloat(points[3][0]), y: parseFloat(points[3][1]), }, }; } /** * Is a given region polygon element of the box-model highlighter currently * hidden? */ async _isRegionHidden(region) { const value = await this.getHighlighterNodeAttribute( "box-model-" + region, "hidden" ); return value !== null; } async _getGuideStatus(location) { const id = "box-model-guide-" + location; const hidden = await this.getHighlighterNodeAttribute(id, "hidden"); const x1 = await this.getHighlighterNodeAttribute(id, "x1"); const y1 = await this.getHighlighterNodeAttribute(id, "y1"); const x2 = await this.getHighlighterNodeAttribute(id, "x2"); const y2 = await this.getHighlighterNodeAttribute(id, "y2"); return { visible: !hidden, x1, y1, x2, y2, }; } /** * Get the coordinates of the rectangle that is defined by the 4 guides displayed * in the toolbox box-model highlighter. * @return {Object} Null if at least one guide is hidden. Otherwise an object * with p1, p2, p3, p4 properties being {x, y} objects. */ async getGuidesRectangle() { const tGuide = await this._getGuideStatus("top"); const rGuide = await this._getGuideStatus("right"); const bGuide = await this._getGuideStatus("bottom"); const lGuide = await this._getGuideStatus("left"); if ( !tGuide.visible || !rGuide.visible || !bGuide.visible || !lGuide.visible ) { return null; } return { p1: { x: lGuide.x1, y: tGuide.y1 }, p2: { x: +rGuide.x1 + 1, y: tGuide.y1 }, p3: { x: +rGuide.x1 + 1, y: +bGuide.y1 + 1 }, p4: { x: lGuide.x1, y: +bGuide.y1 + 1 }, }; } /** * Get the "d" attribute value for one of the box-model highlighter's region * elements, and parse it to a list of points. * @param {String} region The box model region name. * @param {Front} highlighter The front of the highlighter. * @return {Object} The object returned has the following form: * - d {String} the d attribute value * - points {Array} an array of all the polygons defined by the path. Each box * is itself an Array of points, themselves being [x,y] coordinates arrays. */ async getHighlighterRegionPath(region, highlighter) { const d = await this.getHighlighterNodeAttribute( `box-model-${region}`, "d", highlighter ); if (!d) { return { d: null }; } const polygons = d.match(/M[^M]+/g); if (!polygons) { return { d }; } const points = []; for (const polygon of polygons) { points.push( polygon .trim() .split(" ") .map(i => { return i.replace(/M|L/, "").split(","); }) ); } return { d, points }; } } protocol.registerFront(HighlighterTestFront); /** * Check whether a point is included in a polygon. * Taken and tweaked from: * https://github.com/iominh/point-in-polygon-extended/blob/master/src/index.js#L30-L85 * @param {Array} point [x,y] coordinates * @param {Array} polygon An array of [x,y] points * @return {Boolean} */ function isInside(point, polygon) { if (polygon.length === 0) { return false; } // Reduce the length of the fractional part because this is likely to cause errors when // the point is on the edge of the polygon. point = point.map(n => n.toFixed(2)); polygon = polygon.map(p => p.map(n => n.toFixed(2))); const n = polygon.length; const newPoints = polygon.slice(0); newPoints.push(polygon[0]); let wn = 0; // loop through all edges of the polygon for (let i = 0; i < n; i++) { // Accept points on the edges const r = isLeft(newPoints[i], newPoints[i + 1], point); if (r === 0) { return true; } if (newPoints[i][1] <= point[1]) { if (newPoints[i + 1][1] > point[1] && r > 0) { wn++; } } else if (newPoints[i + 1][1] <= point[1] && r < 0) { wn--; } } if (wn === 0) { dumpn(JSON.stringify(point) + " is outside of " + JSON.stringify(polygon)); } // the point is outside only when this winding number wn===0, otherwise it's inside return wn !== 0; } function isLeft(p0, p1, p2) { const l = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]); return l; }