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 = ( diff --git a/src/embed.js b/src/embed.js index 32883394..4bc6d17f 100644 --- a/src/embed.js +++ b/src/embed.js @@ -89,16 +89,3 @@ function onDocReady(fn) { } onDocReady(initPlaygrounds); - -// -// -// -//

-// See the Pen -// VPVqjZ by Stephan Meijer (@smeijer) -// on CodePen. -//

-// diff --git a/src/hooks/usePlayground.js b/src/hooks/usePlayground.js index 0c1b9a8d..35f8b3fc 100644 --- a/src/hooks/usePlayground.js +++ b/src/hooks/usePlayground.js @@ -20,6 +20,7 @@ function reducer(state, action) { result: parser.parse({ markup: action.markup, query: state.query, + rootNode: state.rootNode, prevResult: state.result, }), }; @@ -40,6 +41,7 @@ function reducer(state, action) { result: parser.parse({ markup: state.markup, query: action.query, + rootNode: state.rootNode, prevResult: state.result, }), }; @@ -52,18 +54,19 @@ function reducer(state, action) { } function usePlayground(props) { - let { markup, query, onChange, instanceId } = props; + let { markup, query, onChange, instanceId, rootNode } = props || {}; if (!markup && !query) { markup = defaultValues.markup; query = defaultValues.query; } - const result = parser.parse({ markup, query, cacheId: instanceId }); + const result = parser.parse({ rootNode, markup, query, cacheId: instanceId }); const [state, dispatch] = useReducer(withLogging(reducer), { - result, + rootNode, markup, query, + result, }); useEffect(() => { diff --git a/src/lib/cssPath.js b/src/lib/cssPath.js index abf62780..4aa5b6ef 100644 --- a/src/lib/cssPath.js +++ b/src/lib/cssPath.js @@ -51,7 +51,7 @@ function nodeNameInCorrectCase(node) { */ WebInspector.DOMPresentationUtils.cssPath = function (node, optimized) { if (node.nodeType !== Node.ELEMENT_NODE) { - return ''; + return []; } let steps = []; @@ -73,7 +73,12 @@ WebInspector.DOMPresentationUtils.cssPath = function (node, optimized) { } steps.reverse(); - return steps.join(' > '); + + steps.toString = function () { + return this.join(' > '); + }; + + return steps; }; /** diff --git a/src/lib/ensureArray.js b/src/lib/ensureArray.js index b5e8f39f..ab8d15c4 100644 --- a/src/lib/ensureArray.js +++ b/src/lib/ensureArray.js @@ -1,3 +1,7 @@ export function ensureArray(collection) { - return Array.isArray(collection) ? collection : [collection]; + return collection instanceof NodeList + ? Array.from(collection) + : Array.isArray(collection) + ? collection + : [collection]; } diff --git a/src/lib/queryAdvise.js b/src/lib/queryAdvise.js index cf236cf9..34bbe091 100644 --- a/src/lib/queryAdvise.js +++ b/src/lib/queryAdvise.js @@ -1,6 +1,7 @@ import { messages, queries } from '../constants'; import { computeAccessibleName, getRole } from 'dom-accessibility-api'; import { getSuggestedQuery } from '@testing-library/dom'; +import cssPath from './cssPath'; export function getData({ rootNode, element }) { const type = element.getAttribute('type'); @@ -36,7 +37,9 @@ export function getData({ rootNode, element }) { }; } -const emptyResult = { data: {}, suggestion: {} }; +// TODO: +// TestingLibraryDom.getSuggestedQuery($0, 'get').toString() +export const emptyResult = { data: {}, suggestion: {} }; export function getQueryAdvise({ rootNode, element }) { if ( !rootNode || @@ -49,10 +52,14 @@ export function getQueryAdvise({ rootNode, element }) { const data = getData({ rootNode, element }); if (!suggestedQuery) { + // this will always work, but returns something potentially nasty, like: + // '#tsf > div:nth-child(2) > div:nth-child(1) > div:nth-child(4)' + const path = cssPath(element, true); + return { suggestion: { level: 3, - expression: 'container.querySelector(…)', + expression: `container.querySelector('${path}')`, method: '', ...messages[3], }, diff --git a/src/lib/queryAdvise.test.js b/src/lib/queryAdvise.test.js index 547e5fbd..353c8f6c 100644 --- a/src/lib/queryAdvise.test.js +++ b/src/lib/queryAdvise.test.js @@ -11,7 +11,9 @@ it('should return default suggested query if none was returned by dtl', () => { const rootNode = document.createElement('div'); const element = document.createElement('faketag'); const result = getQueryAdvise({ rootNode, element }); - expect(result.suggestion.expression).toEqual('container.querySelector(…)'); + expect(result.suggestion.expression).toEqual( + "container.querySelector('faketag')", + ); }); it('should return an empty object if root node is a malformed object', () => { diff --git a/src/parser.js b/src/parser.js index d5f7362f..e23d4cef 100644 --- a/src/parser.js +++ b/src/parser.js @@ -16,7 +16,7 @@ const debug = (element, maxLength, options) => ? element.map((el) => logDOM(el, maxLength, options)).join('\n') : logDOM(element, maxLength, options); -function getScreen(root) { +export function getScreen(root) { return getQueriesForElement(root, queries, { debug }); } @@ -69,7 +69,7 @@ function getLastExpression(code) { const [method, ...args] = call .split(/[(),]/) .filter(Boolean) - .map((x) => unQuote(x.trim())); + .map((x) => unQuote((x || '').trim())); const expression = [scope, call].filter(Boolean).join('.'); const level = supportedQueries.find((x) => x.method === method)?.level ?? 3; @@ -84,6 +84,62 @@ function getLastExpression(code) { }; } +function createEvaluator({ rootNode }) { + const context = Object.assign({}, queries, { + screen: getScreen(rootNode), + container: rootNode, + }); + + const evaluator = Function.apply(null, [ + ...Object.keys(context), + 'expr', + 'return eval(expr)', + ]); + + function wrap(cb, extraData = {}) { + let result = { ...extraData }; + + try { + result.data = cb(); + } catch (e) { + const error = e.message.split('\n'); + + result.error = { + message: error[0], + details: error.slice(1).join('\n').trim(), + }; + } + + result.elements = ensureArray(result.data) + .filter((x) => x?.nodeType === Node.ELEMENT_NODE) + .map((element) => { + const { suggestion, data } = getQueryAdvise({ + rootNode, + element, + }); + + return { + suggestion, + data, + target: element, + cssPath: cssPath(element, true), + }; + }); + + result.accessibleRoles = getRoles(rootNode); + return result; + } + + function exec(context, expr) { + return evaluator.apply(null, [ + ...Object.values(context), + (expr || '').trim(), + ]); + } + + return { context, evaluator, exec, wrap }; +} + function createSandbox({ markup }) { // render the frame in a container, so we can set "display: none". If the // hiding would be done in the frame itself, testing-library would mark the @@ -111,25 +167,17 @@ function createSandbox({ markup }) { document.body.appendChild(container); const sandbox = frame.contentDocument || frame.contentWindow.document; - - const context = Object.assign({}, queries, { - screen: getScreen(sandbox.body), - container: sandbox.body, + const { context, evaluator, wrap } = createEvaluator({ + rootNode: sandbox.body, }); - const evaluator = Function.apply(null, [ - ...Object.keys(context), - 'expr', - 'return eval(expr)', - ]); - const script = sandbox.createElement('script'); script.setAttribute('type', 'text/javascript'); script.innerHTML = ` window.exec = function exec(context, expr) { const evaluator = ${evaluator}; - return evaluator.apply(null, [...Object.values(context), expr.trim()]); + return evaluator.apply(null, [...Object.values(context), (expr || '').trim()]); } `; @@ -146,22 +194,8 @@ function createSandbox({ markup }) { body = html; } }, - eval: (script) => { - try { - return { - data: frame.contentWindow.exec(context, script), - }; - } catch (e) { - const error = e.message.split('\n'); - - return { - error: { - message: error[0], - details: error.slice(1).join('\n').trim(), - }, - }; - } - }, + eval: (query) => + wrap(() => frame.contentWindow.exec(context, query), { markup, query }), destroy: () => document.body.removeChild(container), }; } @@ -184,26 +218,6 @@ function runInSandbox({ markup, query, cacheId }) { sandbox.ensureMarkup(markup); const result = sandbox.eval(query); - result.markup = markup; - result.query = query; - - result.elements = ensureArray(result.data) - .filter((x) => x?.nodeType === Node.ELEMENT_NODE) - .map((element) => { - const { suggestion, data } = getQueryAdvise({ - rootNode: sandbox.rootNode, - element, - }); - - return { - suggestion, - data, - target: result.data, - cssPath: cssPath(result.data, true), - }; - }); - - result.accessibleRoles = getRoles(sandbox.rootNode); if (cacheId && !sandboxes[cacheId]) { sandboxes[cacheId] = sandbox; @@ -214,8 +228,28 @@ function runInSandbox({ markup, query, cacheId }) { return result; } -function parse({ markup, query, cacheId, prevResult }) { - const result = runInSandbox({ markup, query, cacheId }); +function runUnsafe({ rootNode, query }) { + const evaluator = createEvaluator({ rootNode }); + + const result = evaluator.wrap( + () => evaluator.exec(evaluator.context, query), + { + query, + markup: rootNode.innerHTML, + }, + ); + + return result; +} + +function parse({ rootNode, markup, query, cacheId, prevResult }) { + if (!markup && !rootNode) { + throw new Error('either markup or rootNode should be provided'); + } + + const result = rootNode + ? runUnsafe({ rootNode, query }) + : runInSandbox({ markup, query, cacheId }); result.expression = getLastExpression(query); diff --git a/src/styles/app.pcss b/src/styles/app.pcss index e948822f..e71a7253 100644 --- a/src/styles/app.pcss +++ b/src/styles/app.pcss @@ -8,6 +8,10 @@ html { @apply h-full; } +body { + @apply text-base; +} + body:not(.embedded) { @apply bg-gray-100; } @@ -50,6 +54,15 @@ nav a:not(.title):hover { height: calc(100% - 2rem); } +.h-half { + height: 50%; +} + +.grid-equal-cells { + grid-auto-rows: 1fr; + grid-auto-columns: 1fr; +} + blockquote { font-family: Georgia, serif; font-size: 18px; @@ -115,6 +128,10 @@ blockquote cite:before { @apply border text-white bg-red-600 p-4 rounded; } +button:disabled { + opacity: .2; +} + /** * preview styles */