diff --git a/ui-devtools/cdap-get-data-testid/README.md b/ui-devtools/cdap-get-data-testid/README.md new file mode 100644 index 00000000000..542949f30dd --- /dev/null +++ b/ui-devtools/cdap-get-data-testid/README.md @@ -0,0 +1,52 @@ +# [Chrome extension] cdap-get-data-testid +_Updated on: 30 January 2024_ + +## Why is it needed ? + +Currently the cdap-ui codebase is tightly coupled with the e2e-tests codebases across the cdap ecosystem. +This is caused by a lack of clear and established contract between the test and application codebases. +Ideally, the application codebase should **always** provide `data-testid` attributes for UI elements +that need to be accessed by the test code. However, this is not followed consistently in case of cdap-ui. +Therefore, many times the test engineers have resorted to writing UI element locators in their test code +using CSS selectors or similar xpath selectors. This method of locating UI elements is inherently dependent +on the DOM structure, making it difficult to make structural changes to the UI without breaking the tests. + +To decouple the UI code and test code, we need to standardize the accepted methods of writing UI element +locators in test code (i.e. only accept test code which locate UI elements based on `data-testid`). But, as +in many cases, the UI elements do not have `data-testid` attributes, we also need to establish an easy +process of identifying such elements and communicating them to the UI engineers, so that the UI engineers +can add `data-testid` attributes on these elements in the UI code. + +This chrome extension is provided here to +1. make it easy for test engineers and UI engineers to find elements with `data-testid` attributes visually +2. make it easy for test engineers to communicate clearly (in a mostly automated process) the cases where the `data-testid` attributes are not present. +3. make it easy for the UI engineers to consume the communicated information about UI elements lacking the `data-testid` attributes, so that they can easily update the UI codebase to add the required attributes. + +## How does it work ? + +### Installation + +1. Clone the cdap-ui repository (if not cloned already). +2. The chrome extension code is located in the directory `cdap-ui/ui-devtools/cdap-get-data-testid`. This directory will be referred as **extension root**. +3. Open a new chrome tab and go to the page `chrome://extensions/` . +4. At the top left of the chrome extensions page, click the **Load unpacked** button. +5. Select the **extension root** directory in the file dialog. +6. This should install the extension. Now you may want to pin the extension to the toolbar for easier access. (recommended) + +### Usage + +This section assumes that the extension has been installed and pinned to the chrome toolbar. + +1. The extension is in disabled (OFF) mode initially. (Indicated by the OFF text on the extension icon) +2. Clicking the extension icon should open a popup with an **Enable** button. +3. Clicking the **Enable** button will modify the webpage, so that hovering on UI elements indicates if the `data-testid` attribute is present on the element or not. +4. If `data-testid` is present, the element is highlighted in green and the value of the `data-testid` attribute is displayed as a popover. Clicking such elements (green) copies the value of `data-testid` to clipboard. +5. If `data-testid` is not present, the element is highlighted in red. **Clicking such elements takes a screenshot of the page and copies the screenshot to the clipboard. It also opens an email (prefilled with details about the element). Please paste the copied screenshot (copied automatically) in the email body before sending it.** +6. The extension also allows you to configure the email address to which such emails will be sent. + +## Contributing + +Currently this extension is written in vanilla javascript and html using the chrome apis. The source code is +in the **extension root** directory. We are not providing this extension as a packaged tool right now. +Currently only the source distribution of this extension is maintained. Code contributions in form of PRs +are welcome. Please consider reporting any issues or feature requests as Github issues. diff --git a/ui-devtools/cdap-get-data-testid/images/icon-32.png b/ui-devtools/cdap-get-data-testid/images/icon-32.png new file mode 100644 index 00000000000..a9644aacb7c Binary files /dev/null and b/ui-devtools/cdap-get-data-testid/images/icon-32.png differ diff --git a/ui-devtools/cdap-get-data-testid/index.html b/ui-devtools/cdap-get-data-testid/index.html new file mode 100644 index 00000000000..2c8c462931c --- /dev/null +++ b/ui-devtools/cdap-get-data-testid/index.html @@ -0,0 +1,69 @@ + + + + + + + + +
+

+ Configuration +

+

Email missing data-testid alerts to:

+ +
+ + + + + diff --git a/ui-devtools/cdap-get-data-testid/manifest.json b/ui-devtools/cdap-get-data-testid/manifest.json new file mode 100644 index 00000000000..559f132862a --- /dev/null +++ b/ui-devtools/cdap-get-data-testid/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "cdap-get-data-testid", + "description": "Developer tool for getting data-testid of any UI element if present, otherwise send an email to a configured email id to add a data-testid for the element in the cdap-ui codebase.", + "version": "1.0", + "action": { + "default_popup": "index.html", + "default_icon": "images/icon-32.png" + }, + "icons": { + "32": "images/icon-32.png" + }, + "background": { + "service_worker": "serviceWorker.js" + }, + "permissions": ["activeTab", "scripting", "storage", "clipboardWrite"] +} diff --git a/ui-devtools/cdap-get-data-testid/popup.js b/ui-devtools/cdap-get-data-testid/popup.js new file mode 100644 index 00000000000..d2c6b51b068 --- /dev/null +++ b/ui-devtools/cdap-get-data-testid/popup.js @@ -0,0 +1,68 @@ +/* + * Copyright © 2024 Cask Data, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +const enableBtn = document.getElementById("enable_button"); +const saveBtn = document.getElementById("save_button"); +const emailInput = document.getElementById("email_input"); + +(async () => { + const port = chrome.runtime.connect({ name: "cdap-get-data-testid" }); + port.postMessage({ action: 'get_state' }); + + saveBtn.addEventListener('click', async function() { + const emailIds = emailInput.value; + port.postMessage({ action: "set_email_ids", emailIds }); + }); + + enableBtn.addEventListener('click', async function() { + port.postMessage({ action: enableBtn.innerText === 'Enable' + ? 'enable_testid_finder' : 'disable_testid_finder' }); + window.close(); + }); + + port.onMessage.addListener(function(msg) { + switch (msg.action) { + case 'state_change': + updateUiState(msg.active, msg.emailIds); + return; + + case 'saved_emails': + blinkSaved(); + return; + + default: + return; + } + }); +})(); + + +function updateUiState(active, emailIds) { + if (active) { + enableBtn.innerText = 'Disable'; + enableBtn.style.background = 'red'; + } else { + enableBtn.innerText = 'Enable'; + enableBtn.style.background = 'green'; + } + + emailInput.value = emailIds; +} + +function blinkSaved() { + saveBtn.innerText = 'Saved'; + window.setTimeout(() => saveBtn.innerText = 'Save', 500); +} diff --git a/ui-devtools/cdap-get-data-testid/serviceWorker.js b/ui-devtools/cdap-get-data-testid/serviceWorker.js new file mode 100644 index 00000000000..d16eff171ba --- /dev/null +++ b/ui-devtools/cdap-get-data-testid/serviceWorker.js @@ -0,0 +1,342 @@ +/* + * Copyright © 2024 Cask Data, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +async function getCurrentTab() { + const [tab] = await chrome.tabs.query({active: true, lastFocusedWindow: true}); + return tab; +} + +async function setActionTextAndColor(text, color, tabId) { + await chrome.action.setBadgeText({ + tabId, + text, + }); + await chrome.action.setBadgeTextColor({ + tabId, + color, + }); +} + +async function getActivationStatus() { + const tab = await getCurrentTab(); + const actionText = await chrome.action.getBadgeText({ tabId: tab.id }); + return actionText === 'ON'; +} + +async function activateAction(tabId) { + await setActionTextAndColor('ON', 'green', tabId); +} + +async function deactivateAction(tabId) { + await setActionTextAndColor('OFF', 'black', tabId); +} + +chrome.runtime.onInstalled.addListener(() => { + setActionTextAndColor('OFF', 'black'); +}); + +async function notifyStateChange(port) { + const emailIds = await getConfiguredEmailIds(); + const active = await getActivationStatus(); + return port.postMessage({ + action: 'state_change', + emailIds, + active, + }); +} + +chrome.runtime.onConnect.addListener(function(port) { + if (port.name !== 'cdap-get-data-testid') return; + + port.onMessage.addListener(async function(request) { + switch (request.action) { + case 'enable_testid_finder': + await enableTestidFinder(); + await notifyStateChange(port); + return; + + case 'disable_testid_finder': + await disableTestidFinder(); + await notifyStateChange(port); + return; + + case 'get_state': + await notifyStateChange(port); + return; + + case 'set_email_ids': + await configureEmailIds(request.emailIds); + await port.postMessage({ action: 'saved_emails' }); + return; + + default: + return; + } + }); +}); + +async function enableTestidFinder() { + const tab = await getCurrentTab(); + const emailIds = await getConfiguredEmailIds(); + chrome.scripting.executeScript({ + target: {tabId: tab.id, allFrames: true}, + func: modifyPage, + args: [emailIds], + }); + + await activateAction(tab.id); +} + +async function disableTestidFinder() { + const tab = await getCurrentTab(); + chrome.scripting.executeScript({ + target: {tabId: tab.id, allFrames: true}, + func: unModifyPage, + }); + + await deactivateAction(tab.id); +} + +function modifyPage(emailIds) { + async function captureScreen () { + const swidth = window.screen.width; + const sheight = window.screen.height; + const canvasId = "cdap-get-testid-screenshot-canvas"; + + const wrapper = document.createElement('DIV'); + document.body.appendChild(wrapper); + wrapper.innerHTML = ``; + const canvas = document.getElementById(canvasId); + canvas.style.opacity = 0; + const context = canvas.getContext("2d"); + + const video = document.createElement("VIDEO"); + document.body.appendChild(video); + video.autoplay = true; + video.style.opacity = 0; + + try { + const captureStream = await navigator.mediaDevices.getDisplayMedia({ preferCurrentTab: true }); + video.srcObject = captureStream; + video.load(); + await new Promise((res) => video.addEventListener('loadeddata', res)); + + context.drawImage(video, 0, 0, swidth, sheight); + captureStream.getTracks().forEach(track => track.stop()); + + await new Promise((resolve, reject) => { + canvas.toBlob(async (png) => { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': png + }) + ]); + return resolve(); + } catch (err) { + return reject(err); + } + }); + }); + + document.body.removeChild(video); + document.body.removeChild(wrapper); + return true; + } catch (err) { + console.error("Error: " + err); + return false; + } + } + + function getDOMPath(el) { + const stack = []; + while (el.parentNode !== null ) { + const nodeName = el.nodeName; + let siblingCount = 0; + let siblingIndex = 0; + for (let i=0; i 1) { + stack.unshift(`${nodeName.toLowerCase()}:eq(${siblingIndex})`); + } else { + stack.unshift(nodeName.toLowerCase()); + } + + el = el.parentNode; + } + + return stack.slice(1).join(' > '); + } + + function makeStyle(obj) { + return Object.entries(obj).map(([key, val]) => `${key}:${val}`).join(';'); + } + + const info = document.createElement('DIV'); + info.style = makeStyle({ + position: 'fixed', + background: 'white', + color: 'black', + display: 'none', + width: 'fit-content', + padding: '8px', + border: '2px black dashed', + fontWeight: 'bold', + }); + document.body.appendChild(info); + + async function handleNodeClick(e) { + e.preventDefault(); + e.stopPropagation(); + + const node = e.target; + if (node.dataset.testid) { + await navigator.clipboard.writeText(node.dataset.testid); + info.innerText = `[COPIED] ${node.dataset.testid}`; + window.setTimeout(() => { info.innerText = node.dataset.testid; }, 300); + return; + } + + node.dataset.cdapGetTestidClicked = true; + const url = window.location.href; + const path = getDOMPath(node); + const emailIdList = emailIds.split(',').map(x => x.trim()).join(','); + const subject = window.encodeURIComponent(`[cdap-ui-alert][e2e-tests] Missing data-testid`); + const screenshotCaptured = await captureScreen(); + + const body = window.encodeURIComponent(` + Hi, + + The following ui element should have a data-testid. + + Page URL: ${url} + Element path: ${path} + Justification: [Add justification here] + Suggested testid: [Suggest a data-testid here] + + + Screenshot: [ Paste screenshot here ] + `); + + delete node.dataset.cdapGetTestidClicked; + unhighlightNode(node); + window.open(`mailto:${emailIdList}?subject=${subject}&body=${body}`); + } + + function onMouseOver(e) { + const node = e.target; + if (node.dataset.cdapGetTestidHovered) return; + + node.dataset.cdapGetTestidHovered = true; + if (node.dataset.testid) { + const offsets = node.getBoundingClientRect(); + info.style.top = `${Math.max(e.clientY - 20, 10)}px`; + info.style.left = `${Math.min(e.clientX + 20, window.screen.width - 100)}px`; + info.style.display = 'block'; + info.style.zIndex = 99999; + info.innerText = node.dataset.testid; + highlightNode(node, 'rgba(153, 255, 102, 0.5)', 'green') + } else { + highlightNode(node, 'rgba(255, 153, 51, 0.5)', 'red') + } + + node.addEventListener('click', handleNodeClick, true); + } + + function onMouseOut(e) { + const node = e.target; + delete node.dataset.cdapGetTestidHovered; + if (!node.dataset.cdapGetTestidClicked) { + unhighlightNode(node); + } + node.removeEventListener('click', handleNodeClick, true); + info.style.display = 'none'; + info.style.innerText = ''; + } + + function highlightNode(node, highlightColor, outlineColor) { + const oldOutline = node.style.outline; + const oldZindex = node.style.zIndex; + const oldPosition = node.style.position; + const oldBackground = node.style.background; + + if (!oldPosition) { + node.style.position = 'relative'; + } + node.style.outline = `1px ${outlineColor} solid`; + node.style.zIndex = 99999; + node.style.background = highlightColor; + + node.dataset.cdapGetTestidOldStyles = JSON.stringify({ + oldOutline, + oldZindex, + oldPosition, + oldBackground, + }); + } + + function unhighlightNode(node) { + if (!node.dataset.cdapGetTestidOldStyles) return; + + const { + oldOutline, + oldPosition, + oldZindex, + oldBackground, + } = JSON.parse(node.dataset.cdapGetTestidOldStyles); + + node.style.outline = oldOutline; + node.style.position = oldPosition; + node.style.zIndex = oldZindex; + node.style.background = oldBackground; + delete node.dataset.cdapGetTestidOldStyles; + } + + document.body.addEventListener('mouseover', onMouseOver); + document.body.addEventListener('mouseout', onMouseOut); + + window.__disableCdapGetDataTestIdExtension = function () { + document.body.removeEventListener('mouseover', onMouseOver); + document.body.removeEventListener('mouseout', onMouseOut); + document.body.removeChild(info); + } +} + +function unModifyPage() { + if (window.__disableCdapGetDataTestIdExtension) { + window.__disableCdapGetDataTestIdExtension(); + delete window.__disableCdapGetDataTestIdExtension; + } +} + +async function getConfiguredEmailIds() { + const result = await chrome.storage.local.get(['emailIds']); + return result.emailIds || 'cdap-ui-eng@google.com'; +} + +function configureEmailIds(emailIds) { + chrome.storage.local.set({ emailIds }); +}