From 2b954768ff05560cc1b93a44eafaa3213ce0a4cb Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 12 Jun 2020 10:09:58 +0200 Subject: [PATCH 1/8] feat: use css path for query selector suggestion --- src/components/Preview.js | 3 ++- src/lib/cssPath.js | 9 +++++++-- src/lib/queryAdvise.js | 11 +++++++++-- src/lib/queryAdvise.test.js | 4 +++- 4 files changed, 21 insertions(+), 6 deletions(-) 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 }) {
'); + + steps.toString = function () { + return this.join(' > '); + }; + + return steps; }; /** 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', () => { From 49988b7ac239cbf27e8e13fa79f00d4de58daed2 Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 12 Jun 2020 10:18:52 +0200 Subject: [PATCH 2/8] chore: add unsafe evaluator to eval outside of sandbox This will allow us to use sandboxed evaluation on testing-playground.com, while running queries unsandboxed in window when using devtools. --- src/hooks/usePlayground.js | 9 ++- src/parser.js | 136 +++++++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 54 deletions(-) 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/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); From db78fca7dbf3c437267a8bcbfe151dfe43a6c98f Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 12 Jun 2020 10:21:47 +0200 Subject: [PATCH 3/8] fix: support NodeList when ensuring arrays --- src/lib/ensureArray.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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]; } From fb694ffa625bd40df2c4edd9c4f95fe477f7ab29 Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 12 Jun 2020 10:35:23 +0200 Subject: [PATCH 4/8] fix: chrome devtools don't support navigator.clipboard --- src/components/ResultCopyButton.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/ResultCopyButton.js b/src/components/ResultCopyButton.js index 41f976cb..fc8f5553 100644 --- a/src/components/ResultCopyButton.js +++ b/src/components/ResultCopyButton.js @@ -1,12 +1,14 @@ import React, { useState, useEffect } from 'react'; +const IS_DEVTOOL = !!window?.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 +45,8 @@ const SuccessIcon = ( const CopyIcon = ( From f83db4597eb3c91adcfaf46cdabaf98c0632ef8a Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 12 Jun 2020 10:36:48 +0200 Subject: [PATCH 5/8] fix: data and suggestion should not be undefined --- src/components/Result.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Result.js b/src/components/Result.js index 283c4805..f771ef83 100644 --- a/src/components/Result.js +++ b/src/components/Result.js @@ -3,6 +3,7 @@ import ErrorBox from './ErrorBox'; import ResultQueries from './ResultQueries'; import ResultSuggestion from './ResultSuggestion'; import Scrollable from './Scrollable'; +import { emptyResult } from '../lib'; function Result({ result, dispatch }) { if (result.error) { @@ -36,7 +37,8 @@ function Result({ result, dispatch }) {
); } - const { data, suggestion } = result.elements[0]; + + const { data, suggestion } = result.elements?.[0] || emptyResult; return (
From 78eee790248dd63e31d67513ec364b1025c816a3 Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 12 Jun 2020 10:37:34 +0200 Subject: [PATCH 6/8] feat: add chrome extension initial version, there are still a lot of features to build --- .babelrc | 5 + devtools/src/background/background.js | 3 + devtools/src/content-script/contentScript.js | 51 +++ .../content-script/highlighter/Highlighter.js | 48 +++ .../src/content-script/highlighter/Overlay.js | 321 ++++++++++++++++++ .../src/content-script/highlighter/index.js | 161 +++++++++ .../src/content-script/highlighter/utils.js | 110 ++++++ devtools/src/content-script/lib/inject.js | 22 ++ .../src/devtools/components/InspectIcon.js | 15 + .../src/devtools/components/LayersIcon.js | 14 + devtools/src/devtools/components/LogIcon.js | 15 + devtools/src/devtools/components/MenuBar.js | 57 ++++ .../src/devtools/components/SelectIcon.js | 15 + devtools/src/devtools/lib/inspectedWindow.js | 25 ++ devtools/src/devtools/main.html | 9 + devtools/src/devtools/main.js | 21 ++ devtools/src/devtools/pane.html | 11 + devtools/src/devtools/pane.js | 33 ++ devtools/src/devtools/panel.html | 11 + devtools/src/devtools/panel.js | 70 ++++ devtools/src/manifest.json | 43 +++ devtools/src/window/testing-library.js | 37 ++ devtools/static/icon.png | Bin 0 -> 431 bytes package-lock.json | 103 ++++++ package.json | 7 + postcss.config.js | 7 +- scripts/build-devtools.js | 32 ++ src/styles/app.pcss | 17 + 28 files changed, 1262 insertions(+), 1 deletion(-) create mode 100644 devtools/src/background/background.js create mode 100644 devtools/src/content-script/contentScript.js create mode 100644 devtools/src/content-script/highlighter/Highlighter.js create mode 100644 devtools/src/content-script/highlighter/Overlay.js create mode 100644 devtools/src/content-script/highlighter/index.js create mode 100644 devtools/src/content-script/highlighter/utils.js create mode 100644 devtools/src/content-script/lib/inject.js create mode 100644 devtools/src/devtools/components/InspectIcon.js create mode 100644 devtools/src/devtools/components/LayersIcon.js create mode 100644 devtools/src/devtools/components/LogIcon.js create mode 100644 devtools/src/devtools/components/MenuBar.js create mode 100644 devtools/src/devtools/components/SelectIcon.js create mode 100644 devtools/src/devtools/lib/inspectedWindow.js create mode 100644 devtools/src/devtools/main.html create mode 100644 devtools/src/devtools/main.js create mode 100644 devtools/src/devtools/pane.html create mode 100644 devtools/src/devtools/pane.js create mode 100644 devtools/src/devtools/panel.html create mode 100644 devtools/src/devtools/panel.js create mode 100644 devtools/src/manifest.json create mode 100644 devtools/src/window/testing-library.js create mode 100644 devtools/static/icon.png create mode 100644 scripts/build-devtools.js 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..f223d08b --- /dev/null +++ b/devtools/src/content-script/contentScript.js @@ -0,0 +1,51 @@ +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'; + +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); +}; 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/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/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..f0702bf6 --- /dev/null +++ b/devtools/src/devtools/main.js @@ -0,0 +1,21 @@ +/* global chrome */ +const panels = chrome.devtools.panels; + +panels.create('Testing Playground', 'icon.png', '/devtools/panel.html'); + +panels.elements.createSidebarPane('Testing Playground', (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..05207ae5 --- /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..44ce152c --- /dev/null +++ b/devtools/src/manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 2, + "name": "Testing Playground Developer Tools", + "description": "Adds Testing Playground to the Chrome Developer Tools.", + "version": "1.0.0", + "version_name": "1.0.0", + + "minimum_chrome_version": "49", + + "icons": { + }, + + "browser_action": { + }, + + "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/devtools/static/icon.png b/devtools/static/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..be8163a9c43a7b2c80cc40fe6b2ab01f2745ad46 GIT binary patch literal 431 zcmV;g0Z{&lP)e3U2?oZU=gj=3C~!}98$YGNX&=*zwL2K?XJ&F>^@`8w!psA!hQIG&+ph+ zka8n%b-0eDg*o(t2H53F<<$x@tELeEgEve+YeKz=1$0 Date: Fri, 12 Jun 2020 10:38:16 +0200 Subject: [PATCH 7/8] chore: remove useless comment block --- src/embed.js | 13 ------------- 1 file changed, 13 deletions(-) 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. -//

-// From f74528e7445718988258f7ade84aa788c7d1024a Mon Sep 17 00:00:00 2001 From: Stephan Meijer Date: Fri, 12 Jun 2020 19:43:06 +0200 Subject: [PATCH 8/8] brush up for firs trelease --- devtools/src/content-script/contentScript.js | 73 ++++++++++-------- devtools/src/content-script/lib/onDocReady.js | 11 +++ devtools/src/devtools/lib/utils.js | 14 ++++ devtools/src/devtools/main.js | 8 +- devtools/src/devtools/pane.js | 2 +- devtools/src/manifest.json | 14 +++- devtools/static/icon.png | Bin 431 -> 0 bytes public/128-production.png | Bin 0 -> 13665 bytes scripts/build-devtools.js | 10 ++- scripts/build.js | 4 +- src/components/ResultCopyButton.js | 3 +- 11 files changed, 97 insertions(+), 42 deletions(-) create mode 100644 devtools/src/content-script/lib/onDocReady.js create mode 100644 devtools/src/devtools/lib/utils.js delete mode 100644 devtools/static/icon.png create mode 100644 public/128-production.png diff --git a/devtools/src/content-script/contentScript.js b/devtools/src/content-script/contentScript.js index f223d08b..401345a5 100644 --- a/devtools/src/content-script/contentScript.js +++ b/devtools/src/content-script/contentScript.js @@ -5,47 +5,52 @@ 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'; -inject('../window/testing-library.js'); -setup(window); +function init() { + inject('../window/testing-library.js'); + setup(window); -window.__TESTING_PLAYGROUND__ = window.__TESTING_PLAYGROUND__ || {}; -const hook = window.__TESTING_PLAYGROUND__; + window.__TESTING_PLAYGROUND__ = window.__TESTING_PLAYGROUND__ || {}; + const hook = window.__TESTING_PLAYGROUND__; -hook.highlighter = setupHighlighter({ view: window, onSelectNode }); + 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, - }); + function onSelectNode(node) { + const { data, suggestion } = getQueryAdvise({ + rootNode: document.body, + element: node, + }); - Bridge.sendMessage('SELECT_NODE', { result, data, suggestion }, 'devtools'); -} + const result = parser.parse({ + rootNode: document.body, + query: suggestion.expression, + }); -Bridge.onMessage('PARSE_QUERY', function ({ data }) { - const result = parser.parse({ - rootNode: document.body, - query: data.query, - }); + Bridge.sendMessage('SELECT_NODE', { result, data, suggestion }, 'devtools'); + } - if (data.highlight) { - hook.highlighter.highlight({ - nodes: (result.elements || []).map((x) => x.target), - hideAfterTimeout: data.hideAfterTimeout, + Bridge.onMessage('PARSE_QUERY', function ({ data }) { + const result = parser.parse({ + rootNode: document.body, + query: data.query, }); - } - return { result }; -}); + 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); + }; +} -// 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/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/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.js b/devtools/src/devtools/main.js index f0702bf6..93160a3c 100644 --- a/devtools/src/devtools/main.js +++ b/devtools/src/devtools/main.js @@ -1,9 +1,13 @@ /* global chrome */ +import { getBrowserName } from './lib/utils'; const panels = chrome.devtools.panels; -panels.create('Testing Playground', 'icon.png', '/devtools/panel.html'); +const isChrome = getBrowserName() === 'Chrome'; +const name = isChrome ? '🐸 Testing Playground' : 'Testing Playground'; -panels.elements.createSidebarPane('Testing Playground', (sidebar) => +panels.create(name, '', '/devtools/panel.html'); + +panels.elements.createSidebarPane(name, (sidebar) => sidebar.setPage('/devtools/pane.html'), ); diff --git a/devtools/src/devtools/pane.js b/devtools/src/devtools/pane.js index 05207ae5..68541eba 100644 --- a/devtools/src/devtools/pane.js +++ b/devtools/src/devtools/pane.js @@ -24,7 +24,7 @@ function Panel() { }; return ( -
+
{result && }
); diff --git a/devtools/src/manifest.json b/devtools/src/manifest.json index 44ce152c..066b849c 100644 --- a/devtools/src/manifest.json +++ b/devtools/src/manifest.json @@ -1,16 +1,26 @@ { "manifest_version": 2, - "name": "Testing Playground Developer Tools", - "description": "Adds Testing Playground to the Chrome Developer Tools.", + "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": [ diff --git a/devtools/static/icon.png b/devtools/static/icon.png deleted file mode 100644 index be8163a9c43a7b2c80cc40fe6b2ab01f2745ad46..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 431 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 literal 0 HcmV?d00001 diff --git a/scripts/build-devtools.js b/scripts/build-devtools.js index aa8092dc..80618596 100644 --- a/scripts/build-devtools.js +++ b/scripts/build-devtools.js @@ -20,7 +20,15 @@ async function main() { }); await copy('devtools/src/manifest.json', join(dest, 'manifest.json')); - await copy('public/icon.png', join(dest, 'icon.png')); + + // 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', { 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/ResultCopyButton.js b/src/components/ResultCopyButton.js index fc8f5553..ab69c8bb 100644 --- a/src/components/ResultCopyButton.js +++ b/src/components/ResultCopyButton.js @@ -1,6 +1,7 @@ +/* global chrome */ import React, { useState, useEffect } from 'react'; -const IS_DEVTOOL = !!window?.chrome?.runtime.id; +const IS_DEVTOOL = !!(window.chrome && chrome.runtime && chrome.runtime.id); /** *