From 469b2d72f6b77d4ba4b3b44fbac1abbb086f13ad Mon Sep 17 00:00:00 2001 From: Nitin Kumar Date: Thu, 14 Dec 2023 14:55:12 +0530 Subject: [PATCH] feat: add getBladeCoverage and assertBladeCoverage utilities (#1864) Co-authored-by: Kamlesh Chandnani --- .changeset/young-mice-join.md | 7 ++ packages/blade/coverageUtils.d.ts | 16 +++ packages/blade/docs/utils/coverge.stories.mdx | 62 ++++++++++ packages/blade/package.json | 9 +- packages/blade/rollup.config.mjs | 46 ++++++- packages/blade/src/utils/bladeCoverage.ts | 112 ++++++++++++++++++ 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 .changeset/young-mice-join.md create mode 100644 packages/blade/coverageUtils.d.ts create mode 100644 packages/blade/docs/utils/coverge.stories.mdx create mode 100644 packages/blade/src/utils/bladeCoverage.ts diff --git a/.changeset/young-mice-join.md b/.changeset/young-mice-join.md new file mode 100644 index 00000000000..e8d5cd20924 --- /dev/null +++ b/.changeset/young-mice-join.md @@ -0,0 +1,7 @@ +--- +'@razorpay/blade': minor +--- + +feat: add `getBladeCoverage` and `assertBladeCoverage` utilities + +Read more about it in the [Blade Coverage documentation](http://blade.razorpay.com/?path=/story/utils-blade-coverage--page). diff --git a/packages/blade/coverageUtils.d.ts b/packages/blade/coverageUtils.d.ts new file mode 100644 index 00000000000..009933a917b --- /dev/null +++ b/packages/blade/coverageUtils.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function getBladeCoverage(): { + bladeCoverage: number; + totalNodes: number; + bladeNodes: number; +}; + +export function assertBladeCoverage({ + page, + expect, + threshold, +}: { + page: any; + expect: any; + threshold?: number; +}): Promise; diff --git a/packages/blade/docs/utils/coverge.stories.mdx b/packages/blade/docs/utils/coverge.stories.mdx new file mode 100644 index 00000000000..376b923b2bc --- /dev/null +++ b/packages/blade/docs/utils/coverge.stories.mdx @@ -0,0 +1,62 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# Blade Coverage Utils + +Blade coverage measures the percentage of a page built with Blade. It does so by calculating the DOM nodes built with Blade vs Non-Blade. All the apps within Razorpay org that are using Blade have the coverage script integrated and you can check the coverage for your app on Amplitude. [Blade Coverage Dashboard](https://app.amplitude.com/analytics/razorpay-mobile/dashboard/texx2y4). + +You can utilize the following utils to measure the coverage in different stages of your app development workflow. + +> These functions are designed for web applications and should be used in a browser environment. + +## `assertBladeCoverage` + +This utility function asserts that the calculated blade coverage meets a specified threshold. + +Parameters: +- **`page`:** Playwright page object. +- **`expect`:** The `expect` function from `@playwright/test`. +- **`threshold` (optional):** Minimum threshold for blade coverage (default is 70). + +### Usage + +- Ensure that you are using Blade v10.22.0 or above and playwright is properly set up. +- Import and use `assertBladeCoverage` in your test files. Adjust the threshold based on your coverage requirements. + + ```js dark + import { test, expect } from '@playwright/test'; + import { assertBladeCoverage } from '@razorpay/blade/coverageUtils'; + + test.describe.parallel('Test Home @flow=home', () => { + test('should have blade coverage more than 70% @priority=normal', async ({ page }) => { + await page.goto('/'); + + await assertBladeCoverage({ page, expect, threshold: 70 }); + }); + }); + ``` + +- Execute your tests using the Playwright Test runner. + + ```bash dark + npx playwright test + ``` + +- Once your tests are passing and blade coverage is meeting expectations, you're good to go! + +## `getBladeCoverage` + +> Consider installing the [Blade Coverage Chrome Extension](https://chromewebstore.google.com/detail/blade-coverage-extension/cpmmcebielcknjffelmpbcbgkcjapipp). This extension provides a convenient way to visualize and analyze the blade coverage directly in the Chrome browser. We internally use the below utility function. + +This utility function calculates the blade usage coverage in percentage of the DOM elements on a web page. + +```js dark +import { getBladeCoverage } from '@razorpay/blade/coverageUtils'; + +const { bladeCoverage, totalNodes, bladeNodes } = getBladeCoverage(); +``` + +- **`bladeCoverage`:** The percentage of blade nodes in the total nodes. +- **`totalNodes`:** Total number of DOM nodes. +- **`bladeNodes`:** Number of blade nodes. diff --git a/packages/blade/package.json b/packages/blade/package.json index 6251ea5af62..404537b5476 100644 --- a/packages/blade/package.json +++ b/packages/blade/package.json @@ -24,7 +24,8 @@ "tokens.native.js", "utils.d.ts", "utils.js", - "utils.native.js" + "utils.native.js", + "coverageUtils.d.ts" ], "keywords": [ "design system", @@ -69,6 +70,12 @@ "production": "./build/lib/web/production/utils/index.js", "default": "./build/lib/web/production/utils/index.js" } + }, + "./coverageUtils": { + "default": { + "types": "./coverageUtils.d.ts", + "default": "./build/lib/web/production/utils/bladeCoverage.js" + } } }, "scripts": { diff --git a/packages/blade/rollup.config.mjs b/packages/blade/rollup.config.mjs index f02c92c5cba..020ce2ed70f 100644 --- a/packages/blade/rollup.config.mjs +++ b/packages/blade/rollup.config.mjs @@ -131,6 +131,48 @@ const getWebConfig = (inputs) => { }; }; +// This is a special config for bladeCoverage.ts +// We need to bundle it as cjs so that we can use it in playwright tests https://github.com/microsoft/playwright/issues/23662 +const getBladeCoverageConfig = (inputs) => { + const platform = 'web'; + const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; + + return { + input: inputs, + // Since we individually bundle each export category (components, tokens, utils) + // it is possible that some function of `utils` is used in `components` but + // rollup will have no idea about it and tree shake it away. + // So we disable tree shaking, this should not cause any issues since consumers will do their own tree shaking. + treeshake: false, + output: [ + { + dir: `${outputRootDirectory}/${libDirectory}/${platform}/${mode}`, + format: 'cjs', + sourcemap: true, + preserveModules: true, + preserveModulesRoot: 'src', + }, + ], + plugins: [ + pluginReplace({ + __DEV__: process.env.NODE_ENV !== 'production', + preventAssignment: true, + }), + pluginPeerDepsExternal(), + depsExternalPlugin({ externalDependencies }), + pluginResolve({ extensions: webExtensions }), + pluginCommonjs(), + pluginBabel({ + exclude: 'node_modules/**', + babelHelpers: 'runtime', + envName: 'production', + extensions: webExtensions, + }), + aliases, + ], + }; +}; + const getNativeConfig = (inputs) => { const platform = 'native'; @@ -229,12 +271,14 @@ const config = () => { const components = 'src/components/index.ts'; const tokens = 'src/tokens/index.ts'; const utils = 'src/utils/index.ts'; + const bladeCoverage = 'src/utils/bladeCoverage.ts'; if (framework === 'REACT') { return [ getWebConfig(components), getWebConfig(tokens), getWebConfig(utils), - // Unfortunately we cannot just simply copy the tsc emited declarations and put it on build dir, + getBladeCoverageConfig(bladeCoverage), + // Unfortunately we cannot just simply copy the tsc emitted declarations and put it on build dir, // because moduleSuffixes will cause typescript to resolve the d.ts files based on the user's tsconfig.json // which will cause the build to fail because the user's tsconfig.json does not have the moduleSuffixes // So we opt for the older approach of bundling the d.ts files as index.d.ts and index.native.d.ts and place it on build dir. diff --git a/packages/blade/src/utils/bladeCoverage.ts b/packages/blade/src/utils/bladeCoverage.ts new file mode 100644 index 00000000000..64fe0b2200d --- /dev/null +++ b/packages/blade/src/utils/bladeCoverage.ts @@ -0,0 +1,112 @@ +const getBladeCoverage = (): { + bladeCoverage: number; + totalNodes: number; + bladeNodes: number; +} => { + /** + * Checks if DOM node is hidden or not + */ + const isElementHidden = (element: Element): boolean => { + if (element.parentElement && isElementHidden(element.parentElement)) { + return true; + } + if (!(element instanceof HTMLElement)) { + return false; + } + if (element.hidden) { + return true; + } + const style = getComputedStyle(element); + return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0'; + }; + + /** + * Checks if DOM node is a media element or not + */ + const isMediaElement = (element: Element): boolean => { + const mediaTags = ['img', 'video', 'audio', 'source', 'picture']; + return mediaTags.includes(element.tagName.toLowerCase()); + }; + + /** + * Checks if DOM element is empty or not + */ + const isElementEmpty = (element: Element): boolean => { + if (!element) return true; + if (!element.childNodes.length) { + return true; + } + return false; + }; + + const allDomElements = document.querySelectorAll('body *'); + + const bladeNodeElements = []; + const totalNodeElements = []; + + allDomElements.forEach((elm) => { + if (isElementHidden(elm)) return; + if (isElementEmpty(elm)) return; + if (isMediaElement(elm)) return; + + // skip svg nodes but not blade icons + const closestSvgNode = elm.closest('svg'); + // if this is a blade icon then add it + if (elm.tagName.toLocaleLowerCase() === 'svg' && elm.hasAttribute('data-blade-component')) { + bladeNodeElements.push(elm); + totalNodeElements.push(elm); + return; + } + // if it's a svg node inside a blade icon then skip it + if (closestSvgNode?.getAttribute('data-blade-component') === 'icon') { + return; + } + // if it's a svg node but not a blade icon then skip it + if (closestSvgNode && !elm.hasAttribute('data-blade-component')) { + return; + } + + totalNodeElements.push(elm); + + // If element has data-blade-component add it + if (elm.hasAttribute('data-blade-component')) { + bladeNodeElements.push(elm); + } + }); + + const totalNodes = totalNodeElements.length; + const bladeNodes = bladeNodeElements.length; + let bladeCoverage = Number(((bladeNodes / totalNodes) * 100).toFixed(2)); + // NaN guard + if (totalNodes === 0) { + bladeCoverage = 0; + } + + return { + bladeCoverage, + totalNodes, + bladeNodes, + }; +}; + +const assertBladeCoverage = async ({ + page, + expect, + threshold = 70, +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + page: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect: any; + threshold: number; +}): Promise => { + const { bladeCoverage } = await page.evaluate((coverageFnStr: string) => { + // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func + const calculateBladeCoverage = new Function(`return (${coverageFnStr})()`); + return calculateBladeCoverage(); + }, getBladeCoverage.toString()); + + expect(bladeCoverage).toBeGreaterThanOrEqual(threshold); +}; + +module.exports = { getBladeCoverage, assertBladeCoverage };