diff --git a/.babelrc b/.babelrc
index 85196b55..cd130775 100644
--- a/.babelrc
+++ b/.babelrc
@@ -2,5 +2,10 @@
"presets": [
"@babel/preset-react",
"@babel/preset-env"
+ ],
+ "plugins": [
+ ["@babel/plugin-proposal-class-properties", {
+ "loose": true
+ }]
]
}
diff --git a/devtools/src/background/background.js b/devtools/src/background/background.js
new file mode 100644
index 00000000..6f10fa91
--- /dev/null
+++ b/devtools/src/background/background.js
@@ -0,0 +1,3 @@
+// This import needs to be here, we don't use the bridge here directly. But
+// simply importing crx-bridge, is what creates the messaging proxy.
+import 'crx-bridge';
diff --git a/devtools/src/content-script/contentScript.js b/devtools/src/content-script/contentScript.js
new file mode 100644
index 00000000..401345a5
--- /dev/null
+++ b/devtools/src/content-script/contentScript.js
@@ -0,0 +1,56 @@
+import Bridge from 'crx-bridge';
+import setupHighlighter from './highlighter';
+
+import parser from '../../../src/parser';
+import { getQueryAdvise } from '../../../src/lib';
+import inject from './lib/inject';
+import { setup } from '../window/testing-library';
+import onDocReady from './lib/onDocReady';
+
+function init() {
+ inject('../window/testing-library.js');
+ setup(window);
+
+ window.__TESTING_PLAYGROUND__ = window.__TESTING_PLAYGROUND__ || {};
+ const hook = window.__TESTING_PLAYGROUND__;
+
+ hook.highlighter = setupHighlighter({ view: window, onSelectNode });
+
+ function onSelectNode(node) {
+ const { data, suggestion } = getQueryAdvise({
+ rootNode: document.body,
+ element: node,
+ });
+
+ const result = parser.parse({
+ rootNode: document.body,
+ query: suggestion.expression,
+ });
+
+ Bridge.sendMessage('SELECT_NODE', { result, data, suggestion }, 'devtools');
+ }
+
+ Bridge.onMessage('PARSE_QUERY', function ({ data }) {
+ const result = parser.parse({
+ rootNode: document.body,
+ query: data.query,
+ });
+
+ if (data.highlight) {
+ hook.highlighter.highlight({
+ nodes: (result.elements || []).map((x) => x.target),
+ hideAfterTimeout: data.hideAfterTimeout,
+ });
+ }
+
+ return { result };
+ });
+
+ // when the selected element is changed by using the element inspector,
+ // this method will be called from devtools/main.js
+ hook.onSelectionChanged = function onSelectionChanged(el) {
+ onSelectNode(el);
+ };
+}
+
+onDocReady(init);
diff --git a/devtools/src/content-script/highlighter/Highlighter.js b/devtools/src/content-script/highlighter/Highlighter.js
new file mode 100644
index 00000000..ab79c63f
--- /dev/null
+++ b/devtools/src/content-script/highlighter/Highlighter.js
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ * Copyright (c) 2020, Stephan Meijer
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ **/
+
+import Overlay from './Overlay';
+
+const SHOW_DURATION = 2000;
+
+let timeoutID = null;
+let overlay = null;
+
+export function hideOverlay() {
+ timeoutID = null;
+
+ if (overlay !== null) {
+ overlay.remove();
+ overlay = null;
+ }
+}
+
+export function showOverlay(elements, hideAfterTimeout) {
+ if (window.document == null) {
+ return;
+ }
+
+ if (timeoutID !== null) {
+ clearTimeout(timeoutID);
+ }
+
+ if (elements == null) {
+ return;
+ }
+
+ if (overlay === null) {
+ overlay = new Overlay();
+ }
+
+ overlay.inspect(elements);
+
+ if (hideAfterTimeout) {
+ timeoutID = setTimeout(hideOverlay, SHOW_DURATION);
+ }
+}
diff --git a/devtools/src/content-script/highlighter/Overlay.js b/devtools/src/content-script/highlighter/Overlay.js
new file mode 100644
index 00000000..c77ef039
--- /dev/null
+++ b/devtools/src/content-script/highlighter/Overlay.js
@@ -0,0 +1,321 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ * Copyright (c) 2020, Stephan Meijer
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import assign from 'object-assign';
+import { getElementDimensions, getNestedBoundingClientRect } from './utils';
+
+// Note that the Overlay components are not affected by the active Theme,
+// because they highlight elements in the main Chrome window (outside of devtools).
+// The colors below were chosen to roughly match those used by Chrome devtools.
+
+class OverlayRect {
+ node;
+ border;
+ padding;
+ content;
+
+ constructor(doc, container) {
+ this.node = doc.createElement('div');
+ this.border = doc.createElement('div');
+ this.padding = doc.createElement('div');
+ this.content = doc.createElement('div');
+
+ this.border.style.borderColor = overlayStyles.border;
+ this.padding.style.borderColor = overlayStyles.padding;
+ this.content.style.backgroundColor = overlayStyles.background;
+
+ assign(this.node.style, {
+ borderColor: overlayStyles.margin,
+ pointerEvents: 'none',
+ position: 'fixed',
+ });
+
+ this.node.style.zIndex = '10000000';
+
+ this.node.appendChild(this.border);
+ this.border.appendChild(this.padding);
+ this.padding.appendChild(this.content);
+ container.appendChild(this.node);
+ }
+
+ remove() {
+ if (this.node.parentNode) {
+ this.node.parentNode.removeChild(this.node);
+ }
+ }
+
+ update(box, dims) {
+ boxWrap(dims, 'margin', this.node);
+ boxWrap(dims, 'border', this.border);
+ boxWrap(dims, 'padding', this.padding);
+
+ assign(this.content.style, {
+ height:
+ box.height -
+ dims.borderTop -
+ dims.borderBottom -
+ dims.paddingTop -
+ dims.paddingBottom +
+ 'px',
+ width:
+ box.width -
+ dims.borderLeft -
+ dims.borderRight -
+ dims.paddingLeft -
+ dims.paddingRight +
+ 'px',
+ });
+
+ assign(this.node.style, {
+ top: box.top - dims.marginTop + 'px',
+ left: box.left - dims.marginLeft + 'px',
+ });
+ }
+}
+
+class OverlayTip {
+ tip;
+ nameSpan;
+ dimSpan;
+
+ constructor(doc, container) {
+ this.tip = doc.createElement('div');
+ assign(this.tip.style, {
+ display: 'none', // should be `flex` but 'tip' doesn't support multi elements, which we need
+ flexFlow: 'row nowrap',
+ backgroundColor: '#333740',
+ borderRadius: '2px',
+ fontFamily:
+ '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',
+ fontWeight: 'bold',
+ padding: '3px 5px',
+ pointerEvents: 'none',
+ position: 'fixed',
+ fontSize: '12px',
+ whiteSpace: 'nowrap',
+ });
+
+ this.nameSpan = doc.createElement('span');
+ this.tip.appendChild(this.nameSpan);
+ assign(this.nameSpan.style, {
+ color: '#ee78e6',
+ borderRight: '1px solid #aaaaaa',
+ paddingRight: '0.5rem',
+ marginRight: '0.5rem',
+ });
+ this.dimSpan = doc.createElement('span');
+ this.tip.appendChild(this.dimSpan);
+ assign(this.dimSpan.style, {
+ color: '#d7d7d7',
+ });
+
+ this.tip.style.zIndex = '10000000';
+ container.appendChild(this.tip);
+ }
+
+ remove() {
+ if (this.tip.parentNode) {
+ this.tip.parentNode.removeChild(this.tip);
+ }
+ }
+
+ updateContent(data) {
+ this.nameSpan.textContent = data.name;
+ this.dimSpan.textContent =
+ Math.round(data.width) + 'px × ' + Math.round(data.height) + 'px';
+ }
+
+ updatePosition(dims, bounds) {
+ const tipRect = this.tip.getBoundingClientRect();
+ const tipPos = findTipPos(dims, bounds, {
+ width: tipRect.width,
+ height: tipRect.height,
+ });
+ assign(this.tip.style, tipPos.style);
+ }
+}
+
+export default class Overlay {
+ window;
+ tipBoundsWindow;
+ container;
+ tip;
+ rects;
+
+ constructor() {
+ // Find the root window, because overlays are positioned relative to it.
+ const currentWindow = window.__TESTING_PLAYGROUND_TARGET_WINDOW__ || window;
+ this.window = currentWindow;
+
+ // When opened in shells/dev, the tooltip should be bound by the app iframe, not by the topmost window.
+ const tipBoundsWindow =
+ window.__TESTING_PLAYGROUND_TARGET_WINDOW__ || window;
+ this.tipBoundsWindow = tipBoundsWindow;
+
+ const doc = currentWindow.document;
+ this.container = doc.createElement('div');
+ this.container.style.zIndex = '10000000';
+
+ this.tip = new OverlayTip(doc, this.container);
+ this.rects = [];
+
+ doc.body.appendChild(this.container);
+ }
+
+ remove() {
+ this.tip.remove();
+ this.rects.forEach((rect) => {
+ rect.remove();
+ });
+ this.rects.length = 0;
+ if (this.container.parentNode) {
+ this.container.parentNode.removeChild(this.container);
+ }
+ }
+
+ inspect(nodes) {
+ // We can't get the size of text nodes or comment nodes. React as of v15
+ // heavily uses comment nodes to delimit text.
+ const elements = nodes.filter(
+ (node) => node.nodeType === Node.ELEMENT_NODE,
+ );
+
+ while (this.rects.length > elements.length) {
+ const rect = this.rects.pop();
+ rect.remove();
+ }
+
+ if (elements.length === 0) {
+ return;
+ }
+
+ while (this.rects.length < elements.length) {
+ this.rects.push(new OverlayRect(this.window.document, this.container));
+ }
+
+ const outerBox = {
+ top: Number.POSITIVE_INFINITY,
+ right: Number.NEGATIVE_INFINITY,
+ bottom: Number.NEGATIVE_INFINITY,
+ left: Number.POSITIVE_INFINITY,
+ };
+
+ elements.forEach((element, index) => {
+ const box = getNestedBoundingClientRect(element, this.window);
+ const dims = getElementDimensions(element);
+
+ outerBox.top = Math.min(outerBox.top, box.top - dims.marginTop);
+ outerBox.right = Math.max(
+ outerBox.right,
+ box.left + box.width + dims.marginRight,
+ );
+ outerBox.bottom = Math.max(
+ outerBox.bottom,
+ box.top + box.height + dims.marginBottom,
+ );
+ outerBox.left = Math.min(outerBox.left, box.left - dims.marginLeft);
+
+ const rect = this.rects[index];
+ rect.update(box, dims);
+ });
+
+ const name = elements[0].nodeName.toLowerCase();
+
+ const node = elements[0];
+ const hook = node.ownerDocument.defaultView.__TESTING_PLAYGROUND__;
+
+ let tipData = {
+ target: node,
+ name,
+ ...outerBox,
+ width: outerBox.right - outerBox.left,
+ height: outerBox.top - outerBox.bottom,
+ };
+
+ if (typeof hook.getElementTooltipData === 'function') {
+ tipData = hook.getElementTooltipData(tipData);
+ }
+
+ this.tip.updateContent(tipData);
+
+ const tipBounds = getNestedBoundingClientRect(
+ this.tipBoundsWindow.document.documentElement,
+ this.window,
+ );
+
+ this.tip.updatePosition(
+ {
+ top: outerBox.top,
+ left: outerBox.left,
+ height: outerBox.bottom - outerBox.top,
+ width: outerBox.right - outerBox.left,
+ },
+ {
+ top: tipBounds.top + this.tipBoundsWindow.scrollY,
+ left: tipBounds.left + this.tipBoundsWindow.scrollX,
+ height: this.tipBoundsWindow.innerHeight,
+ width: this.tipBoundsWindow.innerWidth,
+ },
+ );
+ }
+}
+
+function findTipPos(dims, bounds, tipSize) {
+ const tipHeight = Math.max(tipSize.height, 20);
+ const tipWidth = Math.max(tipSize.width, 60);
+ const margin = 5;
+
+ let top;
+ if (dims.top + dims.height + tipHeight <= bounds.top + bounds.height) {
+ if (dims.top + dims.height < bounds.top + 0) {
+ top = bounds.top + margin;
+ } else {
+ top = dims.top + dims.height + margin;
+ }
+ } else if (dims.top - tipHeight <= bounds.top + bounds.height) {
+ if (dims.top - tipHeight - margin < bounds.top + margin) {
+ top = bounds.top + margin;
+ } else {
+ top = dims.top - tipHeight - margin;
+ }
+ } else {
+ top = bounds.top + bounds.height - tipHeight - margin;
+ }
+
+ let left = dims.left + margin;
+ if (dims.left < bounds.left) {
+ left = bounds.left + margin;
+ }
+ if (dims.left + tipWidth > bounds.left + bounds.width) {
+ left = bounds.left + bounds.width - tipWidth - margin;
+ }
+
+ top += 'px';
+ left += 'px';
+ return {
+ style: { top, left },
+ };
+}
+
+function boxWrap(dims, what, node) {
+ assign(node.style, {
+ borderTopWidth: dims[what + 'Top'] + 'px',
+ borderLeftWidth: dims[what + 'Left'] + 'px',
+ borderRightWidth: dims[what + 'Right'] + 'px',
+ borderBottomWidth: dims[what + 'Bottom'] + 'px',
+ borderStyle: 'solid',
+ });
+}
+
+const overlayStyles = {
+ background: 'rgba(120, 170, 210, 0.7)',
+ padding: 'rgba(77, 200, 0, 0.3)',
+ margin: 'rgba(255, 155, 0, 0.4)',
+ border: 'rgba(255, 200, 50, 0.3)',
+};
diff --git a/devtools/src/content-script/highlighter/index.js b/devtools/src/content-script/highlighter/index.js
new file mode 100644
index 00000000..e38f2478
--- /dev/null
+++ b/devtools/src/content-script/highlighter/index.js
@@ -0,0 +1,161 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ * Copyright (c) 2020, Stephan Meijer
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ **/
+import Bridge from 'crx-bridge';
+
+import memoize from 'memoize-one';
+import throttle from 'lodash.throttle';
+
+import { hideOverlay, showOverlay } from './Highlighter';
+
+// This plug-in provides in-page highlighting of the selected element.
+// It is used by the browser extension and the standalone DevTools shell (when connected to a browser).
+let iframesListeningTo = new Set();
+
+function withMessageData(fn) {
+ return ({ data }) => fn(data);
+}
+
+export default function setupHighlighter({
+ view = window,
+ onSelectNode = () => {},
+} = {}) {
+ let isInspecting = false;
+
+ Bridge.onMessage('CLEAR_HIGHLIGHTS', withMessageData(clearHighlights));
+ Bridge.onMessage('HIGHLIGHT_ELEMENTS', withMessageData(highlightElements));
+ Bridge.onMessage('SHUTDOWN', withMessageData(stopInspecting));
+ Bridge.onMessage('START_INSPECTING', withMessageData(startInspecting));
+ Bridge.onMessage('STOP_INSPECTING', withMessageData(stopInspecting));
+
+ function startInspecting() {
+ isInspecting = true;
+ addEventListeners(view);
+ }
+
+ function addEventListeners(view) {
+ // This plug-in may run in non-DOM environments (e.g. React Native).
+ if (view && typeof view.addEventListener === 'function') {
+ view.addEventListener('click', onClick, true);
+ view.addEventListener('mousedown', onMouseEvent, true);
+ view.addEventListener('mouseover', onMouseEvent, true);
+ view.addEventListener('mouseup', onMouseEvent, true);
+ view.addEventListener('pointerdown', onPointerDown, true);
+ view.addEventListener('pointerover', onPointerOver, true);
+ view.addEventListener('pointerup', onPointerUp, true);
+ }
+ }
+
+ function stopInspecting() {
+ hideOverlay();
+ removeEventListeners(view);
+ iframesListeningTo.forEach(function (frame) {
+ try {
+ removeEventListeners(frame.contentWindow);
+ } catch (error) {
+ // This can error when the iframe is on a cross-origin.
+ }
+ });
+ iframesListeningTo = new Set();
+ isInspecting = false;
+ }
+
+ function removeEventListeners(view) {
+ // This plug-in may run in non-DOM environments (e.g. React Native).
+ if (view && typeof view.removeEventListener === 'function') {
+ view.removeEventListener('click', onClick, true);
+ view.removeEventListener('mousedown', onMouseEvent, true);
+ view.removeEventListener('mouseover', onMouseEvent, true);
+ view.removeEventListener('mouseup', onMouseEvent, true);
+ view.removeEventListener('pointerdown', onPointerDown, true);
+ view.removeEventListener('pointerover', onPointerOver, true);
+ view.removeEventListener('pointerup', onPointerUp, true);
+ }
+ }
+
+ function clearHighlights() {
+ hideOverlay();
+ }
+
+ function highlightElements({ nodes, hideAfterTimeout }) {
+ if (isInspecting) {
+ return;
+ }
+
+ if (nodes?.[0]) {
+ const elems = nodes
+ .map((x) => (typeof x === 'string' ? document.querySelector(x) : x))
+ .filter((x) => x.nodeType === Node.ELEMENT_NODE);
+
+ showOverlay(elems, hideAfterTimeout);
+ } else {
+ hideOverlay();
+ }
+ }
+
+ function onClick(event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ stopInspecting();
+ }
+
+ function onMouseEvent(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ function onPointerDown(event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ selectNode(event.target);
+ }
+
+ function onPointerOver(event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const target = event.target;
+
+ if (target.tagName === 'IFRAME') {
+ try {
+ if (!iframesListeningTo.has(target)) {
+ const window = target.contentWindow;
+ addEventListeners(window);
+ iframesListeningTo.add(target);
+ }
+ } catch (error) {
+ // This can error when the iframe is on a cross-origin.
+ }
+ }
+
+ showOverlay([target], false);
+ selectNode(target);
+ }
+
+ function onPointerUp(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ const selectNode = throttle(
+ memoize(onSelectNode),
+ 200,
+ // Don't change the selection in the very first 200ms
+ // because those are usually unintentional as you lift the cursor.
+ { leading: false },
+ );
+
+ return {
+ clear: clearHighlights,
+ highlight: highlightElements,
+ stop: stopInspecting,
+ start: startInspecting,
+ };
+}
diff --git a/devtools/src/content-script/highlighter/utils.js b/devtools/src/content-script/highlighter/utils.js
new file mode 100644
index 00000000..fbe0f90b
--- /dev/null
+++ b/devtools/src/content-script/highlighter/utils.js
@@ -0,0 +1,110 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ * Copyright (c) 2020, Stephan Meijer
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ **/
+
+// Get the window object for the document that a node belongs to,
+// or return null if it cannot be found (node not attached to DOM,
+// etc).
+export function getOwnerWindow(node) {
+ return node.ownerDocument?.defaultView || null;
+}
+
+// Get the iframe containing a node, or return null if it cannot
+// be found (node not within iframe, etc).
+export function getOwnerIframe(node) {
+ return getOwnerWindow(node)?.frameElement || null;
+}
+
+// Get a bounding client rect for a node, with an
+// offset added to compensate for its border.
+export function getBoundingClientRectWithBorderOffset(node) {
+ const dimensions = getElementDimensions(node);
+
+ return mergeRectOffsets([
+ node.getBoundingClientRect(),
+ {
+ top: dimensions.borderTop,
+ left: dimensions.borderLeft,
+ bottom: dimensions.borderBottom,
+ right: dimensions.borderRight,
+ // This width and height won't get used by mergeRectOffsets (since this
+ // is not the first rect in the array), but we set them so that this
+ // object typechecks as a ClientRect.
+ width: 0,
+ height: 0,
+ },
+ ]);
+}
+
+// Add together the top, left, bottom, and right properties of
+// each ClientRect, but keep the width and height of the first one.
+export function mergeRectOffsets(rects) {
+ return rects.reduce((previousRect, rect) => {
+ if (previousRect == null) {
+ return rect;
+ }
+
+ return {
+ top: previousRect.top + rect.top,
+ left: previousRect.left + rect.left,
+ width: previousRect.width,
+ height: previousRect.height,
+ bottom: previousRect.bottom + rect.bottom,
+ right: previousRect.right + rect.right,
+ };
+ });
+}
+
+// Calculate a boundingClientRect for a node relative to boundaryWindow,
+// taking into account any offsets caused by intermediate iframes.
+export function getNestedBoundingClientRect(node, boundaryWindow) {
+ const ownerIframe = getOwnerIframe(node);
+ if (ownerIframe && ownerIframe !== boundaryWindow) {
+ const rects = [node.getBoundingClientRect()];
+ let currentIframe = ownerIframe;
+ let onlyOneMore = false;
+ while (currentIframe) {
+ const rect = getBoundingClientRectWithBorderOffset(currentIframe);
+ rects.push(rect);
+ currentIframe = getOwnerIframe(currentIframe);
+
+ if (onlyOneMore) {
+ break;
+ }
+ // We don't want to calculate iframe offsets upwards beyond
+ // the iframe containing the boundaryWindow, but we
+ // need to calculate the offset relative to the boundaryWindow.
+ if (currentIframe && getOwnerWindow(currentIframe) === boundaryWindow) {
+ onlyOneMore = true;
+ }
+ }
+
+ return mergeRectOffsets(rects);
+ } else {
+ return node.getBoundingClientRect();
+ }
+}
+
+export function getElementDimensions(domElement) {
+ const calculatedStyle = window.getComputedStyle(domElement);
+
+ return {
+ borderLeft: parseInt(calculatedStyle.borderLeftWidth, 10),
+ borderRight: parseInt(calculatedStyle.borderRightWidth, 10),
+ borderTop: parseInt(calculatedStyle.borderTopWidth, 10),
+ borderBottom: parseInt(calculatedStyle.borderBottomWidth, 10),
+ marginLeft: parseInt(calculatedStyle.marginLeft, 10),
+ marginRight: parseInt(calculatedStyle.marginRight, 10),
+ marginTop: parseInt(calculatedStyle.marginTop, 10),
+ marginBottom: parseInt(calculatedStyle.marginBottom, 10),
+ paddingLeft: parseInt(calculatedStyle.paddingLeft, 10),
+ paddingRight: parseInt(calculatedStyle.paddingRight, 10),
+ paddingTop: parseInt(calculatedStyle.paddingTop, 10),
+ paddingBottom: parseInt(calculatedStyle.paddingBottom, 10),
+ };
+}
diff --git a/devtools/src/content-script/lib/inject.js b/devtools/src/content-script/lib/inject.js
new file mode 100644
index 00000000..9c2f2dcd
--- /dev/null
+++ b/devtools/src/content-script/lib/inject.js
@@ -0,0 +1,22 @@
+/* global chrome */
+
+function inject(src) {
+ return new Promise((resolve) => {
+ const target = document.head || document.documentElement;
+
+ const script = document.createElement('script');
+ script.setAttribute('type', 'text/javascript');
+ script.setAttribute(
+ 'src',
+ src.includes('://') ? src : chrome.runtime.getURL(src),
+ );
+ script.addEventListener('load', () => {
+ target.removeChild(script);
+ resolve();
+ });
+
+ target.appendChild(script);
+ });
+}
+
+export default inject;
diff --git a/devtools/src/content-script/lib/onDocReady.js b/devtools/src/content-script/lib/onDocReady.js
new file mode 100644
index 00000000..bfd4ca5b
--- /dev/null
+++ b/devtools/src/content-script/lib/onDocReady.js
@@ -0,0 +1,11 @@
+function onDocReady(fn) {
+ if (document.readyState !== 'loading') {
+ return fn();
+ }
+
+ setTimeout(() => {
+ onDocReady(fn);
+ }, 9);
+}
+
+export default onDocReady;
diff --git a/devtools/src/devtools/components/InspectIcon.js b/devtools/src/devtools/components/InspectIcon.js
new file mode 100644
index 00000000..302dcbcb
--- /dev/null
+++ b/devtools/src/devtools/components/InspectIcon.js
@@ -0,0 +1,15 @@
+import React from 'react';
+
+function InspectIcon() {
+ return (
+
+ );
+}
+
+export default InspectIcon;
diff --git a/devtools/src/devtools/components/LayersIcon.js b/devtools/src/devtools/components/LayersIcon.js
new file mode 100644
index 00000000..4d4fff8e
--- /dev/null
+++ b/devtools/src/devtools/components/LayersIcon.js
@@ -0,0 +1,14 @@
+import React from 'react';
+
+function LayersIcon() {
+ return (
+
+ );
+}
+
+export default LayersIcon;
diff --git a/devtools/src/devtools/components/LogIcon.js b/devtools/src/devtools/components/LogIcon.js
new file mode 100644
index 00000000..761855e2
--- /dev/null
+++ b/devtools/src/devtools/components/LogIcon.js
@@ -0,0 +1,15 @@
+import React from 'react';
+
+function LogIcon() {
+ return (
+
+ );
+}
+
+export default LogIcon;
diff --git a/devtools/src/devtools/components/MenuBar.js b/devtools/src/devtools/components/MenuBar.js
new file mode 100644
index 00000000..8067cd50
--- /dev/null
+++ b/devtools/src/devtools/components/MenuBar.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import Bridge from 'crx-bridge';
+
+import inspectedWindow from '../lib/inspectedWindow';
+
+import SelectIcon from './SelectIcon';
+import LayersIcon from './LayersIcon';
+import InspectIcon from './InspectIcon';
+import LogIcon from './LogIcon';
+
+function MenuBar({ cssPath, suggestion }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default MenuBar;
diff --git a/devtools/src/devtools/components/SelectIcon.js b/devtools/src/devtools/components/SelectIcon.js
new file mode 100644
index 00000000..20fbe1ba
--- /dev/null
+++ b/devtools/src/devtools/components/SelectIcon.js
@@ -0,0 +1,15 @@
+import React from 'react';
+
+function SelectIcon() {
+ return (
+
+ );
+}
+
+export default SelectIcon;
diff --git a/devtools/src/devtools/lib/inspectedWindow.js b/devtools/src/devtools/lib/inspectedWindow.js
new file mode 100644
index 00000000..526970e2
--- /dev/null
+++ b/devtools/src/devtools/lib/inspectedWindow.js
@@ -0,0 +1,25 @@
+/* global chrome */
+
+// We can't do this with messaging, because in Chrome, eval always runs in the
+// context of the ContentScript, not in the context of Window. Maybe we can just
+// do something with `useContentScriptContext: true`, maintain a log on the most
+// recent(ly) used element(s), assign an id to them, and then use messaging. But
+// for now, this is way easier.
+
+function logQuery(query) {
+ chrome.devtools.inspectedWindow.eval(`
+ console.log('${query.replace(/'/g, "\\'")}');
+ console.log(eval(${query}));
+ `);
+}
+
+function inspect(cssPath) {
+ chrome.devtools.inspectedWindow.eval(`
+ inspect(document.querySelector('${cssPath}'));
+ `);
+}
+
+export default {
+ logQuery,
+ inspect,
+};
diff --git a/devtools/src/devtools/lib/utils.js b/devtools/src/devtools/lib/utils.js
new file mode 100644
index 00000000..e5cbf0fe
--- /dev/null
+++ b/devtools/src/devtools/lib/utils.js
@@ -0,0 +1,14 @@
+/* global chrome */
+const IS_CHROME = navigator.userAgent.indexOf('Firefox') < 0;
+
+export function getBrowserName() {
+ return IS_CHROME ? 'Chrome' : 'Firefox';
+}
+
+export function getBrowserTheme() {
+ if (!chrome.devtools || !chrome.devtools.panels) {
+ return 'light';
+ }
+
+ return chrome.devtools.panels.themeName === 'dark' ? 'dark' : 'light';
+}
diff --git a/devtools/src/devtools/main.html b/devtools/src/devtools/main.html
new file mode 100644
index 00000000..4397fbf0
--- /dev/null
+++ b/devtools/src/devtools/main.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/devtools/src/devtools/main.js b/devtools/src/devtools/main.js
new file mode 100644
index 00000000..93160a3c
--- /dev/null
+++ b/devtools/src/devtools/main.js
@@ -0,0 +1,25 @@
+/* global chrome */
+import { getBrowserName } from './lib/utils';
+const panels = chrome.devtools.panels;
+
+const isChrome = getBrowserName() === 'Chrome';
+const name = isChrome ? '🐸 Testing Playground' : 'Testing Playground';
+
+panels.create(name, '', '/devtools/panel.html');
+
+panels.elements.createSidebarPane(name, (sidebar) =>
+ sidebar.setPage('/devtools/pane.html'),
+);
+
+function onSelectionChanged() {
+ chrome.devtools.inspectedWindow.eval(
+ '__TESTING_PLAYGROUND__.onSelectionChanged($0)',
+ {
+ useContentScriptContext: true,
+ },
+ );
+}
+
+panels.elements.onSelectionChanged.addListener(onSelectionChanged);
+
+onSelectionChanged();
diff --git a/devtools/src/devtools/pane.html b/devtools/src/devtools/pane.html
new file mode 100644
index 00000000..f8a71672
--- /dev/null
+++ b/devtools/src/devtools/pane.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/devtools/src/devtools/pane.js b/devtools/src/devtools/pane.js
new file mode 100644
index 00000000..68541eba
--- /dev/null
+++ b/devtools/src/devtools/pane.js
@@ -0,0 +1,33 @@
+import 'regenerator-runtime/runtime';
+import React, { useState, useEffect } from 'react';
+import ReactDOM from 'react-dom';
+import Bridge from 'crx-bridge';
+import Result from '../../../src/components/Result';
+import inspectedWindow from './lib/inspectedWindow';
+
+function Panel() {
+ const [{ result }, setResult] = useState({});
+
+ useEffect(() => {
+ Bridge.onMessage('SELECT_NODE', (result) => {
+ setResult(result.data);
+ });
+ }, []);
+
+ const dispatch = (action) => {
+ switch (action.type) {
+ case 'SET_QUERY': {
+ inspectedWindow.logQuery(action.query);
+ break;
+ }
+ }
+ };
+
+ return (
+
+ {result && }
+
+ );
+}
+
+ReactDOM.render(, document.getElementById('app'));
diff --git a/devtools/src/devtools/panel.html b/devtools/src/devtools/panel.html
new file mode 100644
index 00000000..49bdd3f9
--- /dev/null
+++ b/devtools/src/devtools/panel.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/devtools/src/devtools/panel.js b/devtools/src/devtools/panel.js
new file mode 100644
index 00000000..2144a750
--- /dev/null
+++ b/devtools/src/devtools/panel.js
@@ -0,0 +1,70 @@
+import 'regenerator-runtime/runtime';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
+import ReactDOM from 'react-dom';
+import Bridge from 'crx-bridge';
+import Query from '../../../src/components/Query';
+import Result from '../../../src/components/Result';
+import MenuBar from './components/MenuBar';
+
+function Panel() {
+ const [{ result }, setResult] = useState({ result: {} });
+ const editor = useRef(null);
+
+ useEffect(() => {
+ Bridge.onMessage('SELECT_NODE', ({ data }) => {
+ setResult(data);
+ editor.current.setValue(data.suggestion?.expression || '');
+ });
+ }, [setResult]);
+
+ const dispatch = useCallback(
+ (action) => {
+ switch (action.type) {
+ case 'SET_QUERY': {
+ Bridge.sendMessage(
+ 'PARSE_QUERY',
+ {
+ query: action.query,
+ highlight: true,
+ },
+ 'content-script',
+ ).then((data) => {
+ setResult(data);
+ });
+
+ if (action.updateEditor !== false) {
+ editor.current.setValue(action.query);
+ }
+ break;
+ }
+
+ case 'SET_QUERY_EDITOR': {
+ editor.current = action.editor;
+ }
+ }
+ },
+ [setResult],
+ );
+
+ return (
+
+ );
+}
+
+ReactDOM.render(, document.getElementById('app'));
diff --git a/devtools/src/manifest.json b/devtools/src/manifest.json
new file mode 100644
index 00000000..066b849c
--- /dev/null
+++ b/devtools/src/manifest.json
@@ -0,0 +1,53 @@
+{
+ "manifest_version": 2,
+ "name": "Testing Playground",
+ "description": "Simple and complete DOM testing playground that encourage good testing practices.",
+ "version": "1.0.0",
+ "version_name": "1.0.0",
+
+ "minimum_chrome_version": "49",
+
+ "icons": {
+ "16": "icons/16-production.png",
+ "32": "icons/32-production.png",
+ "48": "icons/48-production.png",
+ "128": "icons/128-production.png"
+ },
+
+ "browser_action": {
+ "default_icon": {
+ "16": "icons/16-production.png",
+ "32": "icons/32-production.png",
+ "48": "icons/48-production.png",
+ "128": "icons/128-production.png"
+ }
+ },
+
+ "web_accessible_resources": [
+ "window/testing-library.js"
+ ],
+
+ "devtools_page": "devtools/main.html",
+
+ "content_security_policy": "script-src 'self' 'unsafe-eval' 'sha256-6UcmjVDygSSU8p+3s7E7Kz8EG/ARhPADPRUm9P90HLM='; object-src 'self'",
+
+ "background": {
+ "scripts": ["background/background.js"],
+ "persistent": false
+ },
+
+ "permissions": [
+ "",
+ "activeTab",
+ "clipboardWrite"
+ ],
+
+ "content_scripts": [
+ {
+ "matches": [""],
+ "js": ["content-script/contentScript.js"],
+ "run_at": "document_start",
+ "all_frames": true
+ }
+ ]
+}
diff --git a/devtools/src/window/testing-library.js b/devtools/src/window/testing-library.js
new file mode 100644
index 00000000..21107aea
--- /dev/null
+++ b/devtools/src/window/testing-library.js
@@ -0,0 +1,37 @@
+import {
+ screen,
+ within,
+ getSuggestedQuery,
+ fireEvent,
+} from '@testing-library/dom';
+window.__TESTING_PLAYGROUND__ = window.__TESTING_PLAYGROUND__ || {};
+
+function augmentQuery(query) {
+ return (...args) => {
+ const result = query(...args);
+
+ // Promise.resolve(result).then((x) => {
+ // if (x.nodeType) {
+ // window.inspect(x);
+ // }
+ // });
+
+ return result;
+ };
+}
+
+export function setup(view) {
+ // monkey patch `screen` to add testing library to console
+ for (const prop of Object.keys(screen)) {
+ view.screen[prop] = view.screen[prop] || augmentQuery(screen[prop]);
+ view[prop] = view.screen[prop];
+ }
+
+ view.fireEvent = fireEvent;
+ view.getSuggestedQuery = getSuggestedQuery;
+ view.within = within;
+
+ view.container = view.document.body;
+}
+
+setup(window);
diff --git a/package-lock.json b/package-lock.json
index b4d809ac..1e28005a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4652,6 +4652,50 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"dev": true
},
+ "chrome-launch": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chrome-launch/-/chrome-launch-1.1.4.tgz",
+ "integrity": "sha1-yJhb0CJjWlzIlwmbr9GYMYOKclI=",
+ "dev": true,
+ "requires": {
+ "chrome-location": "^1.2.0",
+ "quick-tmp": "0.0.0",
+ "rimraf": "^2.2.8",
+ "shallow-copy": "0.0.1"
+ },
+ "dependencies": {
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ }
+ }
+ },
+ "chrome-location": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/chrome-location/-/chrome-location-1.2.1.tgz",
+ "integrity": "sha1-aRFRGk6sVQJ2Jcc7k3ylynq5SZU=",
+ "dev": true,
+ "requires": {
+ "userhome": "^1.0.0",
+ "which": "^1.0.5"
+ },
+ "dependencies": {
+ "which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ }
+ }
+ },
"chrome-trace-event": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz",
@@ -5453,6 +5497,14 @@
"which": "^2.0.1"
}
},
+ "crx-bridge": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/crx-bridge/-/crx-bridge-2.2.0.tgz",
+ "integrity": "sha512-4lvu7C06jD4dvUpY4s6+9ScuwXr69nmWrwnPiNC+Cq/FgCit0PV3Jrd/nNhcHzg0PcKTr9+f/SF76oeWrWeUdQ==",
+ "requires": {
+ "serialize-error": "^2.1.0"
+ }
+ },
"crypto-browserify": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
@@ -7190,6 +7242,12 @@
"semver-regex": "^2.0.0"
}
},
+ "first-match": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/first-match/-/first-match-0.0.1.tgz",
+ "integrity": "sha1-pg7GQnAPD0NyNOu37D84JHblQv0=",
+ "dev": true
+ },
"flat-cache": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz",
@@ -10792,6 +10850,11 @@
"lodash._reinterpolate": "^3.0.0"
}
},
+ "lodash.throttle": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+ "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
+ },
"lodash.toarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
@@ -10971,6 +11034,11 @@
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==",
"dev": true
},
+ "memoize-one": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz",
+ "integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA=="
+ },
"memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
@@ -13564,6 +13632,24 @@
"integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
"dev": true
},
+ "quick-tmp": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/quick-tmp/-/quick-tmp-0.0.0.tgz",
+ "integrity": "sha1-QWXmYyDqBfnLzM874fj9X9sF998=",
+ "dev": true,
+ "requires": {
+ "first-match": "0.0.1",
+ "osenv": "0.0.3"
+ },
+ "dependencies": {
+ "osenv": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.0.3.tgz",
+ "integrity": "sha1-zWrY3bKQkVrZ4idlV2Al1BHynLY=",
+ "dev": true
+ }
+ }
+ },
"raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -14486,6 +14572,11 @@
"integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==",
"dev": true
},
+ "serialize-error": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz",
+ "integrity": "sha1-ULZ51WNc34Rme9yOWa9OW4HV9go="
+ },
"serialize-javascript": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-2.1.2.tgz",
@@ -14543,6 +14634,12 @@
"safe-buffer": "^5.0.1"
}
},
+ "shallow-copy": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz",
+ "integrity": "sha1-QV9CcC1z2BAzApLMXuhurhoRoXA=",
+ "dev": true
+ },
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -16151,6 +16248,12 @@
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==",
"dev": true
},
+ "userhome": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/userhome/-/userhome-1.0.0.tgz",
+ "integrity": "sha1-tkkf8S0hpecmcd+czIcX4cZojAs=",
+ "dev": true
+ },
"util": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.3.tgz",
diff --git a/package.json b/package.json
index 03add91c..05ccd708 100644
--- a/package.json
+++ b/package.json
@@ -5,9 +5,11 @@
"scripts": {
"start": "run-s start:client",
"start:client": "node ./scripts/build-client",
+ "start:devtools": "node ./scripts/build-devtools",
"build": "run-s clean build:*",
"build:client": "cross-env NODE_ENV=production node ./scripts/build-client",
"build:server": "cross-env NODE_ENV=production node ./scripts/build-lambda",
+ "build:devtools": "cross-env NODE_ENV=production node ./scripts/build-devtools",
"lint": "run-s lint:*",
"lint:eslint": "eslint . --quiet --fix",
"lint:prettier": "prettier . --write",
@@ -28,11 +30,14 @@
"dependencies": {
"@testing-library/dom": "^7.12.0",
"codemirror": "5.54.0",
+ "crx-bridge": "^2.1.0",
"deep-diff": "^1.0.2",
"dom-accessibility-api": "^0.4.4",
"js-beautify": "^1.11.0",
"lodash.debounce": "4.0.8",
+ "lodash.throttle": "^4.1.1",
"lz-string": "^1.4.4",
+ "memoize-one": "^5.1.1",
"pretty-format": "26.0.1",
"query-string": "^6.12.1",
"react": "^16.13.1",
@@ -44,6 +49,7 @@
},
"devDependencies": {
"@babel/core": "^7.10.2",
+ "@babel/plugin-proposal-class-properties": "^7.10.1",
"@babel/preset-env": "^7.10.2",
"@babel/preset-react": "^7.10.1",
"@testing-library/jest-dom": "^5.9.0",
@@ -51,6 +57,7 @@
"@testing-library/user-event": "^11.4.2",
"@types/fs-extra": "^9.0.1",
"babel-eslint": "^10.1.0",
+ "chrome-launch": "^1.1.4",
"conventional-changelog": "^3.1.21",
"conventional-changelog-config-spec": "^2.1.0",
"cross-env": "^7.0.2",
diff --git a/postcss.config.js b/postcss.config.js
index 6cf8eaef..851cc9f2 100644
--- a/postcss.config.js
+++ b/postcss.config.js
@@ -16,7 +16,12 @@ if (!IS_DEVELOPMENT) {
plugins.push(
purgecss({
- content: ['src/*.html', 'src/**/*.js'],
+ content: [
+ 'src/*.html',
+ 'src/**/*.js',
+ 'devtools/**/*.js',
+ 'devtools/**/*.html',
+ ],
whitelist: ['body', /CodeMirror/],
whitelistPatternsChildren: [/CodeMirror/, /cm-s-dracula/],
defaultExtractor: TailwindExtractor.extract,
diff --git a/public/128-production.png b/public/128-production.png
new file mode 100644
index 00000000..6d60d745
Binary files /dev/null and b/public/128-production.png differ
diff --git a/scripts/build-devtools.js b/scripts/build-devtools.js
new file mode 100644
index 00000000..80618596
--- /dev/null
+++ b/scripts/build-devtools.js
@@ -0,0 +1,40 @@
+const { join, resolve } = require('path');
+const { copy, remove } = require('fs-extra');
+const { build } = require('./build');
+const chromeLaunch = require('chrome-launch');
+
+async function main() {
+ const dest = resolve('dist/chrome-extension');
+ await remove(dest);
+
+ const parcel = await build({
+ entries: [
+ 'devtools/src/devtools/pane.{html,js}',
+ 'devtools/src/devtools/panel.{html,js}',
+ 'devtools/src/devtools/main.{html,js}',
+ 'devtools/src/content-script/contentScript.js',
+ 'devtools/src/background/background.js',
+ 'devtools/src/window/testing-library.js',
+ ],
+ dest,
+ });
+
+ await copy('devtools/src/manifest.json', join(dest, 'manifest.json'));
+
+ // copy icons that are declared in manifest.json#icons from /public dir
+ const manifest = require(join(dest, 'manifest.json'));
+
+ await Promise.all(
+ Object.values(manifest.icons).map((icon) =>
+ copy(icon.replace(/^icons/, 'public'), join(dest, icon)),
+ ),
+ );
+
+ if (parcel.watching) {
+ chromeLaunch('https://google.com', {
+ args: [`--load-extension=${dest}`, '--auto-open-devtools-for-tabs'],
+ });
+ }
+}
+
+main();
diff --git a/scripts/build.js b/scripts/build.js
index 254f8515..2fb89b5c 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -17,7 +17,9 @@ async function build({
if (serve !== false && port) {
config.serve = { port: await getPort({ port }) };
- } else {
+ }
+
+ if (process.env.NODE_ENV === 'production') {
config.mode = 'production';
}
diff --git a/src/components/Preview.js b/src/components/Preview.js
index 0bb2aaf8..4a7c9335 100644
--- a/src/components/Preview.js
+++ b/src/components/Preview.js
@@ -5,7 +5,7 @@ import AddHtml from './AddHtml';
import { getQueryAdvise } from '../lib';
function selectByCssPath(rootNode, cssPath) {
- return rootNode?.querySelector(cssPath.replace(/^body > /, ''));
+ return rootNode?.querySelector(cssPath.toString().replace(/^body > /, ''));
}
function Preview({ markup, accessibleRoles, elements, dispatch }) {
@@ -94,6 +94,7 @@ function Preview({ markup, accessibleRoles, elements, dispatch }) {
);
}
- const { data, suggestion } = result.elements[0];
+
+ const { data, suggestion } = result.elements?.[0] || emptyResult;
return (
diff --git a/src/components/ResultCopyButton.js b/src/components/ResultCopyButton.js
index 41f976cb..ab69c8bb 100644
--- a/src/components/ResultCopyButton.js
+++ b/src/components/ResultCopyButton.js
@@ -1,12 +1,15 @@
+/* global chrome */
import React, { useState, useEffect } from 'react';
+const IS_DEVTOOL = !!(window.chrome && chrome.runtime && chrome.runtime.id);
+
/**
*
* @param {string} suggestion
*/
async function attemptCopyToClipboard(suggestion) {
try {
- if ('clipboard' in navigator) {
+ if (!IS_DEVTOOL && 'clipboard' in navigator) {
await navigator.clipboard.writeText(suggestion);
return true;
}
@@ -43,8 +46,8 @@ const SuccessIcon = (
const CopyIcon = (