├── .babelrc ├── .czrc ├── .eslintignore ├── .eslintrc.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── node-ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierrc ├── .remarkignore ├── .remarkrc.js ├── README.md ├── commitlint.config.js ├── demo ├── .gitignore ├── .nvmrc ├── README.md ├── package.json ├── public │ ├── favicon.png │ └── index.html ├── src │ ├── App.vue │ ├── assets │ │ └── logo.png │ ├── components │ │ ├── Buttons.vue │ │ └── HelloWorld.vue │ └── main.js ├── vue.config.js └── yarn.lock ├── docs ├── .gitignore ├── .nvmrc ├── README.md ├── package.json ├── src │ ├── .vuepress │ │ ├── components │ │ │ ├── Foo │ │ │ │ └── Bar.vue │ │ │ ├── OtherComponent.vue │ │ │ └── demo-component.vue │ │ ├── config.js │ │ ├── enhanceApp.js │ │ ├── public │ │ │ ├── assets │ │ │ │ └── img │ │ │ │ │ ├── logo.png │ │ │ │ │ └── selector-picker.gif │ │ │ └── favicon.png │ │ ├── styles │ │ │ ├── index.styl │ │ │ └── palette.styl │ │ └── theme │ │ │ ├── index.js │ │ │ └── layouts │ │ │ └── Layout.vue │ ├── README.md │ ├── api │ │ ├── README.md │ │ ├── methods.md │ │ └── properties.md │ ├── guide │ │ ├── README.md │ │ ├── getting-started.md │ │ ├── namespacing.md │ │ ├── plugin-options.md │ │ ├── selector-picker.md │ │ └── usage.md │ └── index.md └── yarn.lock ├── jest.config.js ├── package.json ├── plugin └── src │ ├── api.js │ ├── api.spec.js │ ├── directive.js │ ├── directive.spec.js │ ├── index.js │ └── install.spec.js ├── renovate.json ├── vite.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-transform-runtime", 7 | "@babel/plugin-proposal-class-properties" 8 | ], 9 | "comments": false 10 | } 11 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | coverage 3 | docs 4 | **/dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | jest: true, 5 | node: true, 6 | }, 7 | 8 | extends: [ 9 | 'plugin:nuxt/recommended', 10 | 'plugin:vue/recommended', 11 | 'plugin:prettier-vue/recommended', 12 | 'plugin:@crishellco/recommended', 13 | 'prettier', 14 | ], 15 | globals: { 16 | __basedir: true, 17 | createStore: true, 18 | }, 19 | overrides: [ 20 | { 21 | files: ['*.vue'], 22 | rules: { 23 | 'sort-keys-fix/sort-keys-fix': 'off', 24 | }, 25 | }, 26 | ], 27 | parserOptions: { 28 | parser: '@babel/eslint-parser', 29 | requireConfigFile: false, 30 | }, 31 | plugins: ['jest-formatting', '@crishellco', 'sort-keys-fix', 'unused-imports'], 32 | root: true, 33 | rules: { 34 | 'jest-formatting/padding-around-describe-blocks': 2, 35 | 'jest-formatting/padding-around-test-blocks': 2, 36 | 'newline-before-return': 'error', 37 | 'sort-keys-fix/sort-keys-fix': 'warn', 38 | 'unused-imports/no-unused-imports': 'error', 39 | 'vue/component-name-in-template-casing': ['error', 'kebab-case'], 40 | 'vue/html-self-closing': [ 41 | 'error', 42 | { 43 | html: { 44 | component: 'always', 45 | normal: 'always', 46 | void: 'always', 47 | }, 48 | math: 'always', 49 | svg: 'always', 50 | }, 51 | ], 52 | 'vue/multi-word-component-names': 'off', 53 | 'vue/new-line-between-multi-line-property': 'error', 54 | 'vue/padding-line-between-blocks': 'error', 55 | 'vue/require-name-property': 'error', 56 | 'vue/static-class-names-order': 'error', 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Changes 2 | 3 | - item 4 | 5 | ### Screenshots 6 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 'on': 3 | push: 4 | branches: 5 | - master 6 | - alpha 7 | - beta 8 | pull_request: 9 | types: 10 | - opened 11 | - synchronize 12 | 13 | env: 14 | FORCE_COLOR: 1 15 | NPM_CONFIG_COLOR: always 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | 18 | jobs: 19 | verify: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Read .nvmrc 24 | run: 'echo ::set-output name=NVMRC::$(cat .nvmrc)' 25 | id: nvm 26 | - name: Setup node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '${{ steps.nvm.outputs.NVMRC }}' 30 | - run: yarn install 31 | - run: yarn test 32 | env: 33 | CI: true 34 | - uses: codecov/codecov-action@v3 35 | - run: yarn build 36 | - run: yarn release 37 | env: 38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | *.bak 4 | .history 5 | yarn-error.log 6 | coverage 7 | dist -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "jsxBracketSameLine": true, 4 | "printWidth": 120, 5 | "singleQuote": true, 6 | "trailingComma": "es5" 7 | } -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | docs 2 | -------------------------------------------------------------------------------- /.remarkrc.js: -------------------------------------------------------------------------------- 1 | exports.plugins = [ 2 | '@form8ion/remark-lint-preset', 3 | ['remark-toc', {tight: true}], 4 | ['validate-links', { repository: false }], 5 | ['remark-lint-maximum-line-length', 100] 6 | ]; 7 | 8 | exports.settings = { 9 | listItemIndent: 1, 10 | emphasis: '_', 11 | strong: '_', 12 | bullet: '*', 13 | incrementListMarker: false 14 | }; 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |


2 | 3 |

4 | Build 5 | codecov 6 | Maintainability 7 |
8 |

9 | 10 | # Vue Hubble 11 | 12 | Vue test selectors made easy. 13 | 14 | Vue Hubble makes it simple to add selectors (only in your testing environment) 15 | and target component elements in tests without worrying 16 | about collisions, extraneous classes, etc. 17 | 18 | Check out the [demo](http://vue-hubble-demo.crishell.co) 19 | 20 | ## Table of Contents 21 | 22 | * [Documentation](#documentation) 23 | * [How to Contribute](#how-to-contribute) 24 | * [Pull Requests](#pull-requests) 25 | * [License](#license) 26 | 27 | ## Documentation 28 | 29 | Please visit . 30 | 31 | ## How to Contribute 32 | 33 | ### Pull Requests 34 | 35 | 1. Fork the repository 36 | 1. Create a new branch for each feature or improvement 37 | 1. Please follow [semantic-release commit format](https://semantic-release.gitbook.io/semantic-release/#commit-message-format) 38 | 1. Send a pull request from each feature branch to the __develop__ branch 39 | 40 | ## License 41 | 42 | [MIT](http://opensource.org/licenses/MIT) 43 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@form8ion'] }; 2 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /demo/.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # demo 2 | 3 | ## Table of Contents 4 | 5 | * [Project setup](#project-setup) 6 | * [Compiles and hot-reloads for development](#compiles-and-hot-reloads-for-development) 7 | * [Compiles and minifies for production](#compiles-and-minifies-for-production) 8 | * [Lints and fixes files](#lints-and-fixes-files) 9 | * [Customize configuration](#customize-configuration) 10 | 11 | ## Project setup 12 | 13 | yarn install 14 | 15 | ### Compiles and hot-reloads for development 16 | 17 | yarn serve 18 | 19 | ### Compiles and minifies for production 20 | 21 | yarn build 22 | 23 | ### Lints and fixes files 24 | 25 | yarn lint 26 | 27 | ### Customize configuration 28 | 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-hubble-demo", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "serve": "vue-cli-service serve", 6 | "build": "vue-cli-service build" 7 | }, 8 | "author": "Chris Mitchell (@crishellco)", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/crishellco/vue-hubble.git" 12 | }, 13 | "dependencies": { 14 | "vue": "^3" 15 | }, 16 | "devDependencies": { 17 | "@vue/cli-service": "5.0.8" 18 | }, 19 | "browserslist": [ 20 | "> 1%", 21 | "last 2 versions", 22 | "not dead" 23 | ], 24 | "license": "MIT" 25 | } 26 | -------------------------------------------------------------------------------- /demo/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crishellco/vue-hubble/835f39836ec9e207619d832d7ad395d23922e1d6/demo/public/favicon.png -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /demo/src/App.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | 26 | 36 | -------------------------------------------------------------------------------- /demo/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crishellco/vue-hubble/835f39836ec9e207619d832d7ad395d23922e1d6/demo/src/assets/logo.png -------------------------------------------------------------------------------- /demo/src/components/Buttons.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | 22 | 27 | -------------------------------------------------------------------------------- /demo/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | 14 | 15 | 31 | -------------------------------------------------------------------------------- /demo/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp, h } from 'vue'; 2 | 3 | import App from './App.vue'; 4 | import Hubble from '../../plugin/src'; 5 | 6 | const app = createApp({ 7 | name: 'DemoMain', 8 | 9 | render() { 10 | return h(App); 11 | }, 12 | }); 13 | 14 | app.use(Hubble, { enableSelectorPicker: true, environment: ['development', 'production', 'test'], prefix: 'demo' }); 15 | app.mount('#app'); 16 | -------------------------------------------------------------------------------- /demo/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pages: { 3 | index: { 4 | entry: 'src/main.js', 5 | title: 'Vue Hubble Demo', 6 | }, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | pids 2 | logs 3 | node_modules 4 | npm-debug.log 5 | coverage/ 6 | run 7 | dist 8 | .DS_Store 9 | .nyc_output 10 | .basement 11 | config.local.js 12 | basement_dist 13 | -------------------------------------------------------------------------------- /docs/.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # docs 2 | 3 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-hubble-docs", 3 | "version": "0.0.1", 4 | "description": "Vue test selectors made easy", 5 | "main": "index.js", 6 | "author": "Chris Mitchell (@crishellco)", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/crishellco/vue-hubble.git" 10 | }, 11 | "scripts": { 12 | "dev": "vuepress dev src", 13 | "build": "cross-env NODE_OPTIONS=--openssl-legacy-provider vuepress build src -d dist" 14 | }, 15 | "license": "MIT", 16 | "devDependencies": { 17 | "cross-env": "^7.0.3", 18 | "vuepress": "1.9.10" 19 | }, 20 | "dependencies": { 21 | "vuepress-plugin-dehydrate": "^1.1.5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/Foo/Bar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/OtherComponent.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /docs/src/.vuepress/components/demo-component.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /docs/src/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | const { description } = require('../../package'); 2 | 3 | module.exports = { 4 | base: '/', 5 | /** 6 | * Ref:https://v1.vuepress.vuejs.org/config/#title 7 | */ 8 | title: 'Vue Hubble 4.x', 9 | /** 10 | * Ref:https://v1.vuepress.vuejs.org/config/#description 11 | */ 12 | description: description, 13 | 14 | /** 15 | * Extra tags to be injected to the page HTML `` 16 | * 17 | * ref:https://v1.vuepress.vuejs.org/config/#head 18 | */ 19 | head: [ 20 | ['link', { rel: 'icon', href: '/favicon.png' }], 21 | ['meta', { name: 'theme-color', content: '#3eaf7c' }], 22 | ['meta', { name: 'apple-mobile-web-app-capable', content: 'yes' }], 23 | ['meta', { name: 'apple-mobile-web-app-status-bar-style', content: 'black' }], 24 | ], 25 | 26 | /** 27 | * Theme configuration, here is the default theme configuration for VuePress. 28 | * 29 | * ref:https://v1.vuepress.vuejs.org/theme/default-theme-config.html 30 | */ 31 | themeConfig: { 32 | logo: '/assets/img/logo.png', 33 | repo: 'https://github.com/crishellco/vue-hubble', 34 | editLinks: false, 35 | docsDir: '', 36 | editLinkText: '', 37 | lastUpdated: false, 38 | nav: [ 39 | { 40 | text: 'Home', 41 | link: '/', 42 | }, 43 | { 44 | text: 'Guide', 45 | link: '/guide/getting-started.md', 46 | }, 47 | { 48 | text: 'API', 49 | link: '/api/methods.md', 50 | }, 51 | { 52 | text: 'Demo', 53 | link: 'http://vue-hubble-demo.crishell.co/', 54 | }, 55 | { 56 | text: 'v4.x', 57 | items: [ 58 | { 59 | text: 'v3.x', 60 | link: 'https://vue-hubble-3x.crishell.co', 61 | }, 62 | ], 63 | }, 64 | ], 65 | sidebar: { 66 | '/guide/': [ 67 | { 68 | title: 'Guide', 69 | collapsable: false, 70 | children: ['getting-started', 'plugin-options', 'usage', 'selector-picker'], 71 | }, 72 | { 73 | title: 'Advanced', 74 | collapsable: false, 75 | children: ['namespacing'], 76 | }, 77 | ], 78 | '/api/': [ 79 | { 80 | title: 'API', 81 | collapsable: false, 82 | children: ['methods', 'properties'], 83 | }, 84 | ], 85 | }, 86 | }, 87 | 88 | /** 89 | * Apply plugins,ref:https://v1.vuepress.vuejs.org/zh/plugin/ 90 | */ 91 | plugins: ['@vuepress/plugin-back-to-top', '@vuepress/plugin-medium-zoom', 'dehydrate'], 92 | }; 93 | -------------------------------------------------------------------------------- /docs/src/.vuepress/enhanceApp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Client app enhancement file. 3 | * 4 | * https://v1.vuepress.vuejs.org/guide/basic-config.html#app-level-enhancements 5 | */ 6 | 7 | export default ({ 8 | Vue, // the version of Vue being used in the VuePress app 9 | options, // the options for the root Vue instance 10 | router, // the router instance for the app 11 | siteData // site metadata 12 | }) => { 13 | // ...apply enhancements for the site. 14 | router.addRoute( 15 | { path: '/', redirect: '/guide' }, 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /docs/src/.vuepress/public/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crishellco/vue-hubble/835f39836ec9e207619d832d7ad395d23922e1d6/docs/src/.vuepress/public/assets/img/logo.png -------------------------------------------------------------------------------- /docs/src/.vuepress/public/assets/img/selector-picker.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crishellco/vue-hubble/835f39836ec9e207619d832d7ad395d23922e1d6/docs/src/.vuepress/public/assets/img/selector-picker.gif -------------------------------------------------------------------------------- /docs/src/.vuepress/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crishellco/vue-hubble/835f39836ec9e207619d832d7ad395d23922e1d6/docs/src/.vuepress/public/favicon.png -------------------------------------------------------------------------------- /docs/src/.vuepress/styles/index.styl: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom Styles here. 3 | * 4 | * ref:https://v1.vuepress.vuejs.org/config/#index-styl 5 | */ 6 | 7 | .home .hero img 8 | max-width 450px!important 9 | -------------------------------------------------------------------------------- /docs/src/.vuepress/styles/palette.styl: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom palette here. 3 | * 4 | * ref:https://v1.vuepress.vuejs.org/zh/config/#palette-styl 5 | */ 6 | 7 | $accentColor = #6366f1 8 | $textColor = #111827 9 | $borderColor = #eaecef 10 | $codeBgColor = #282c34 11 | -------------------------------------------------------------------------------- /docs/src/.vuepress/theme/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extend: '@vuepress/theme-default', 3 | }; 4 | -------------------------------------------------------------------------------- /docs/src/.vuepress/theme/layouts/Layout.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | 31 | 46 | 47 | 62 | -------------------------------------------------------------------------------- /docs/src/README.md: -------------------------------------------------------------------------------- 1 | # src 2 | 3 | -------------------------------------------------------------------------------- /docs/src/api/README.md: -------------------------------------------------------------------------------- 1 | # api 2 | 3 | -------------------------------------------------------------------------------- /docs/src/api/methods.md: -------------------------------------------------------------------------------- 1 | # Methods 2 | 3 | ## window.$hubble.all 4 | 5 | Gets all elements with hubble selectors. 6 | 7 | `window.$hubble.all(): HTMLElement[]` 8 | 9 | ## window.$hubble.allMapped 10 | 11 | Gets all elements with hubble selectors, mapped by selector. 12 | 13 | `window.$hubble.allMapped(): { [string]: HTMLElement }` 14 | 15 | ## window.$hubble.find 16 | 17 | Finds all elements with hubble selectors matching the passed selector. 18 | 19 | `window.$hubble.find(selector: string): HTMLElement[]]` 20 | 21 | ## window.$hubble.findMapped 22 | 23 | Finds all elements with hubble selectors matching the passed selector, mapped by selector. 24 | 25 | `window.$hubble.findMapped(selector: string): { [string]: HTMLElement }` 26 | 27 | ## window.$hubble.first 28 | 29 | Finds the first element with hubble selectors matching the passed selector. 30 | 31 | `window.$hubble.first(selector: string): HTMLElement | undefined` 32 | 33 | ## window.$hubble.resetOptions 34 | 35 | Resets the plugin options to their initial state when the plugin was first installed. 36 | 37 | `window.$hubble.resetOptions(): void` 38 | -------------------------------------------------------------------------------- /docs/src/api/properties.md: -------------------------------------------------------------------------------- 1 | # Properties 2 | 3 | ## window.$hubble.options 4 | 5 | The plugin options values. 6 | 7 | __Note__: The `options` object is reactive. Changes to it will cause 8 | elements with the Vue Hubble directive to update. 9 | 10 | `window.$hubble.options: { [string]: boolean | string[] | string }` 11 | -------------------------------------------------------------------------------- /docs/src/guide/README.md: -------------------------------------------------------------------------------- 1 | # guide 2 | 3 | -------------------------------------------------------------------------------- /docs/src/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Install Package 4 | 5 | ```bash 6 | yarn add -D @crishellco/vue-hubble 7 | ``` 8 | 9 | ## Add The Plugin To Your App 10 | 11 | See the [Plugin Options](/guide/plugin-options.md) docs for more info. 12 | 13 | ```javascript 14 | import VueHubble from '@crishellco/vue-hubble'; 15 | 16 | const options = { 17 | defaultSelectorType: 'attr', 18 | enableComments: false, 19 | enableDeepNamespacing: true, 20 | enableSelectorPicker: false, 21 | environment: 'test', 22 | enableGroupedSelectors: true, 23 | prefix: '', 24 | }; 25 | 26 | Vue.use(VueHubble, options); 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/src/guide/namespacing.md: -------------------------------------------------------------------------------- 1 | # Namespacing 2 | 3 | ## Deep Namespacing 4 | 5 | If the [Plugin Option](/guide/plugin-options.md) `enableDeepNamespacing` is `true` (default), 6 | Vue Hubble will automatically namespace all selectors in a given component by using 7 | it's own and it's ancestral component namespaces. Deep namespacing recurses 8 | up the component tree, ignoring missing or empty namespace values, 9 | to create a selector prefixed by joined(`--` delimiter) 10 | ancestral namespaces. 11 | 12 | ### Generated Selector Naming Convention 13 | 14 | `{parent namespace}--{child namespace}--{directive hubble selector}` 15 | 16 | ### Example 17 | 18 | #### Options API 19 | 20 | ```html 21 | 22 | 25 | 26 | 31 | 32 | 33 | 36 | 37 | 45 | 46 |
47 | ``` 48 | 49 | #### Composition API 50 | 51 | ```html 52 | 53 | 56 | 57 | 60 | 61 | 62 | 63 | 66 | 67 | 70 | 71 |
72 | ``` 73 | 74 | ## Shallow Namespacing 75 | 76 | If the [Plugin Option](/guide/plugin-options.md) `enableDeepNamespacing` is `false`, 77 | Vue Hubble will automatically namespace all selectors in a given 78 | component by using only it's own component namespace. 79 | 80 | ### Example 81 | 82 | #### Options API 83 | 84 | ```html 85 | 86 | 89 | 90 | 95 | 96 | 97 | 100 | 101 | 109 | 110 |
111 | ``` 112 | 113 | #### Composition API 114 | ```html 115 | 116 | 119 | 120 | 123 | 124 | 125 | 128 | 129 | 132 | 133 |
134 | ``` 135 | -------------------------------------------------------------------------------- /docs/src/guide/plugin-options.md: -------------------------------------------------------------------------------- 1 | # Plugin Options 2 | 3 | | Name | Type | Default | Description | 4 | |--------------------------|-------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------| 5 | | `defaultSelectorType` | `string` | `attr` | Defines the selector type if not passed into the directive `v-hubble:attr` | 6 | | `enableComments` | `boolean` | `false` | Enables or disables comments around elements with hubble selectors | 7 | | `enableDeepNamespacing` | `boolean` | `true` | Enables or disables auto recursive namespacing | 8 | | `enableSelectorPicker` | `boolean` | `false` | Enables or disables the selector picker feature | 9 | | `environment` | `string or array` | `test` | Defines the environment(s) in which these selectors are added.

**Note:** Use `*` for all environments. | 10 | | `enableGroupedSelectors` | `boolean` | `true` | Enables or disables grouping the `vue-hubble-selector` attribute value with `[vue-hubble]` | 11 | | `prefix` | `string` | `''` | Prefixes all selectors with the value and `--`, if value exists. For example, if `prefix = 'qa'`, all selectors well begin with`qa--` | 12 | -------------------------------------------------------------------------------- /docs/src/guide/selector-picker.md: -------------------------------------------------------------------------------- 1 | # Selector Picker 2 | 3 | The Selector Picker is similar to the element picker in Chrome Dev Tools, except it shows a tooltip 4 | (which copies the Vue-Hubble selector when clicked) when you 5 | hover over an element which has Vue-Hubble applied. 6 | 7 | ![selector-picker](/assets/img/selector-picker.gif) 8 | 9 | ## Enable Selector Picker 10 | 11 | You can enable the selector three ways: 12 | 13 | __1. Use the [Vue Hubble Official Browser Extension](https://chrome.google.com/webstore/detail/vue-hubble/kgmcnpoibbdnlheneapenlckppkfhejh/related)__ :rocket: 14 | 15 | __2. Set `enableSelectorPicker` to `true` when installing Vue-Hubble__ 16 | 17 | ```javascript 18 | Vue.use(VueHubble, { enableSelectorPicker: true }); 19 | ``` 20 | 21 | __3. Use the console in dev tools to set `window.$hubble.options.enableSelectorPicker` to `true`__ 22 | 23 | ```javascript 24 | $ window.$hubble.options.enableSelectorPicker = true; 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/src/guide/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Directive 4 | 5 | Use the directive to add test selectors to elements you wish to test. 6 | 7 | ### Template Example 8 | 9 | ```vue 10 | 25 | ``` 26 | 27 | ### Resulting Markup 28 | 29 | ```html 30 |
31 | 32 |
33 | 34 |
35 | 36 |
37 | ``` 38 | 39 | ## Writing Tests 40 | 41 | [Examples](https://github.com/crishellco/vue-hubble/blob/master/plugin/src/directive.spec.js) 42 | 43 | ```javascript 44 | describe('directive.js', () => { 45 | it('should add an attribute selector', () => { 46 | const wrapper = mount({ 47 | template: '
' 48 | }); 49 | 50 | expect(wrapper.contains('[vue-hubble][selector]')).toBe(true); 51 | }); 52 | }); 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/src/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroImage: /assets/img/logo.png 4 | actionText: Get Started → 5 | actionLink: /guide/getting-started.md 6 | footer: MIT Licensed | Copyright © 2020-present Chris Mitchell 7 | --- 8 | 9 |

10 | Build 11 | codecov 12 | Maintainability 13 |
14 |

15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | collectCoverageFrom: ['plugin/**/*.{js,vue}'], 5 | coverageReporters: ['json-summary', 'text', 'lcov'], 6 | moduleFileExtensions: [ 7 | 'js', 8 | 'json', 9 | // tell Jest to handle `*.vue` files 10 | 'vue', 11 | ], 12 | testEnvironment: 'jsdom', 13 | transform: { 14 | // process `*.vue` files with `vue-jest` 15 | '.*\\.(vue)$': 'vue-jest', 16 | '^.+\\.js$': '/node_modules/babel-jest', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@crishellco/vue-hubble", 3 | "version": "0.0.0-semantically-released", 4 | "author": "Chris Mitchell (@crishellco)", 5 | "description": "Vue test selectors made easy", 6 | "main": "./dist/vue-hubble.umd.js", 7 | "module": "./dist/vue-hubble.es.js", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/vue-hubble.es.js", 11 | "require": "./dist/vue-hubble.umd.js" 12 | } 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "test": "npm-run-all --print-label --parallel lint:* --parallel test:*", 19 | "test:unit": "jest", 20 | "test:unit:changed": "yarn test:unit --changedSince=master", 21 | "test:unit:watch": "yarn test:unit:changed --watch", 22 | "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", 23 | "lint:js:fix": "yarn lint:js --fix", 24 | "lint:md": "remark . --frail", 25 | "build": "rimraf dist && vite build --mode prod", 26 | "generate:md": "remark . --output", 27 | "install:demo": "cd demo && yarn", 28 | "install:docs": "cd docs && yarn", 29 | "release": "semantic-release" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/crishellco/vue-hubble.git" 34 | }, 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/crishellco/vue-hubble/issues" 38 | }, 39 | "homepage": "https://vue-hubble.crishell.co/", 40 | "publishConfig": { 41 | "access": "public" 42 | }, 43 | "devDependencies": { 44 | "@babel/eslint-parser": "7.27.5", 45 | "@commitlint/cli": "19.8.1", 46 | "@crishellco/eslint-plugin": "^1.3.0", 47 | "@form8ion/commitlint-config": "2.0.6", 48 | "@form8ion/remark-lint-preset": "5.0.1", 49 | "@vue/eslint-config-prettier": "10.2.0", 50 | "@vue/test-utils": "^2.0.0", 51 | "babel-core": "6.26.3", 52 | "babel-jest": "29.7.0", 53 | "core-js": "3.42.0", 54 | "cross-env": "7.0.3", 55 | "cz-conventional-changelog": "3.3.0", 56 | "eslint": "^8", 57 | "eslint-config-prettier": "10.1.5", 58 | "eslint-loader": "4.0.2", 59 | "eslint-plugin-import": "^2.31.0", 60 | "eslint-plugin-jest": "28.13.0", 61 | "eslint-plugin-jest-formatting": "^3.1.0", 62 | "eslint-plugin-nuxt": "^4.0.0", 63 | "eslint-plugin-prettier": "5.4.1", 64 | "eslint-plugin-prettier-vue": "4.2.0", 65 | "eslint-plugin-sort-keys-fix": "^1.1.2", 66 | "eslint-plugin-unused-imports": "^4.0.0", 67 | "eslint-plugin-vue": "9.33.0", 68 | "husky": "9.1.7", 69 | "jest": "27.5.1", 70 | "npm-run-all2": "7.0.2", 71 | "poi": "12.10.3", 72 | "prettier": "3.5.3", 73 | "regenerator-runtime": "0.14.1", 74 | "remark-cli": "11.0.0", 75 | "remark-toc": "9.0.0", 76 | "rimraf": "5.0.10", 77 | "semantic-release": "22.0.12", 78 | "vite": "6.3.5", 79 | "vue": "^3.0.0", 80 | "vue-html-loader": "1.2.4", 81 | "vue-jest": "5.0.0-alpha.10", 82 | "vue-loader": "17.4.2", 83 | "vue-style-loader": "4.1.3" 84 | }, 85 | "config": { 86 | "commitizen": { 87 | "path": "./node_modules/cz-conventional-changelog" 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /plugin/src/api.js: -------------------------------------------------------------------------------- 1 | import { NAMESPACE } from './directive'; 2 | 3 | const api = (masterOptions) => ({ 4 | all() { 5 | return [...document.querySelectorAll(`[${NAMESPACE}]`)]; 6 | }, 7 | 8 | allMapped() { 9 | return mapResults(this.all()); 10 | }, 11 | 12 | find(selector) { 13 | return [ 14 | ...document.querySelectorAll(`[${NAMESPACE}][${selector}]`), 15 | ...document.querySelectorAll(`[${NAMESPACE}][class*="${selector}"]`), 16 | ...document.querySelectorAll(`[${NAMESPACE}][id*="${selector}"]`), 17 | ]; 18 | }, 19 | 20 | findMapped(selector) { 21 | return mapResults(this.find(selector)); 22 | }, 23 | 24 | first(selector) { 25 | return this.find(selector).shift(); 26 | }, 27 | 28 | resetOptions() { 29 | window.$hubble.options = { ...masterOptions }; 30 | }, 31 | }); 32 | 33 | function mapResults(nodes) { 34 | return [...nodes].reduce((result, node) => { 35 | return { 36 | ...result, 37 | [node.getAttribute(`${NAMESPACE}-selector`)]: node, 38 | }; 39 | }, {}); 40 | } 41 | 42 | export default api; 43 | -------------------------------------------------------------------------------- /plugin/src/api.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | 3 | import VueHubble from '.'; 4 | import apiFactory from './api'; 5 | import { getQuerySelector } from './directive'; 6 | 7 | process.env.NODE_ENV = 'test'; 8 | 9 | const attachTo = document.createElement('div'); 10 | document.body.appendChild(attachTo); 11 | 12 | let api; 13 | let wrapper; 14 | 15 | describe('api.js', () => { 16 | beforeEach(() => { 17 | wrapper = mount( 18 | { 19 | template: ` 20 |
21 | 22 | 23 | 24 | 25 |
26 | `, 27 | }, 28 | { 29 | attachTo, 30 | global: { 31 | plugins: [VueHubble], 32 | }, 33 | } 34 | ); 35 | api = apiFactory({ ...window.$hubble.options }); 36 | }); 37 | afterEach(() => { 38 | wrapper.unmount(); 39 | }); 40 | 41 | it('all', () => { 42 | const nodes = api.all(); 43 | 44 | expect(nodes.length).toBe(4); 45 | expect(nodes[0].outerHTML.indexOf('attribute-selector')).toBeGreaterThan(-1); 46 | }); 47 | 48 | it('allMapped', () => { 49 | const nodes = api.allMapped(); 50 | 51 | expect(nodes[getQuerySelector('attribute-selector', 'attr', wrapper.vm)]).toBeTruthy(); 52 | }); 53 | 54 | it('find', () => { 55 | expect(api.find('id-selector').length).toBe(1); 56 | expect(api.find('not-a-selector').length).toBe(0); 57 | }); 58 | 59 | it('findMapped', () => { 60 | expect(api.findMapped('id-selector')[getQuerySelector('id-selector', 'id', wrapper.vm)]).toBeTruthy(); 61 | }); 62 | 63 | it('first', () => { 64 | expect(api.find('class-selector').length).toBe(2); 65 | expect(api.first('class-selector')).toEqual(wrapper.element.querySelector('#first-class')); 66 | }); 67 | 68 | it('reset', () => { 69 | window.$hubble.options.environment = ['production']; 70 | 71 | api.resetOptions(); 72 | 73 | expect(window.$hubble.options.environment).toEqual(['test']); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /plugin/src/directive.js: -------------------------------------------------------------------------------- 1 | import { watch } from 'vue'; 2 | export const CLOSING_COMMENT = '//'; 3 | export const NAMESPACE = 'vue-hubble'; 4 | export const ENV_WILDCARD = '*'; 5 | 6 | const COPY_MESSAGE_RESET_TIMEOUT = 1000; 7 | 8 | let $hubble; 9 | 10 | export const get = (obj, path, defaultValue) => { 11 | const travel = (regexp) => 12 | String.prototype.split 13 | .call(path, regexp) 14 | .filter(Boolean) 15 | .reduce((res, key) => { 16 | // istanbul ignore next 17 | return res !== null && res !== undefined ? res[key] : res; 18 | }, obj); 19 | 20 | const result = travel(/[,[\]]+?/) || travel(/[,[\].]+?/); 21 | 22 | return result === undefined || result === obj ? defaultValue : result; 23 | }; 24 | 25 | export const inCorrectEnvironment = () => { 26 | return $hubble.environment.includes(ENV_WILDCARD) || $hubble.environment.includes(process.env.NODE_ENV); 27 | }; 28 | 29 | export const selectorPickerEnabled = () => { 30 | return get($hubble, ['enableSelectorPicker'], false); 31 | }; 32 | 33 | export const getClosingComment = (querySelector) => { 34 | return `${CLOSING_COMMENT} ${querySelector}`; 35 | }; 36 | 37 | export const getComponentNamespace = (component, vnode) => { 38 | const config = get(component.$options, ['hubble'], get(vnode, 'ctx.setupState.hubble', {})); 39 | 40 | return typeof config === 'string' ? config : config.namespace; 41 | }; 42 | 43 | export const getGenericSelector = (instance, vnode, value) => { 44 | if (!value) return ''; 45 | 46 | const namespaces = [value]; 47 | const enableDeepNamespacing = $hubble.enableDeepNamespacing; 48 | const namespace = getComponentNamespace(instance, vnode); 49 | 50 | let $component = instance; 51 | let $vnode; 52 | 53 | if (!enableDeepNamespacing) { 54 | namespaces.push(namespace); 55 | } else { 56 | do { 57 | $vnode = $component.$el.__vnode; 58 | 59 | const namespace = getComponentNamespace($component, $vnode); 60 | 61 | namespace && namespaces.push(namespace); 62 | 63 | if ($component.$el === $component.$parent.$el) break; 64 | 65 | $component = $component.$parent; 66 | } while ($component); 67 | } 68 | 69 | return ( 70 | ($hubble.prefix ? `${$hubble.prefix}--` : '') + 71 | namespaces 72 | .filter((namespace) => !!namespace) 73 | .reverse() 74 | .join('--') 75 | ); 76 | }; 77 | 78 | export const getOpeningComment = (querySelector) => { 79 | return `${querySelector}`; 80 | }; 81 | 82 | export const getQuerySelector = (selector, selectorType) => { 83 | const prefix = $hubble.enableGroupedSelectors ? `[${NAMESPACE}]` : ''; 84 | 85 | switch (selectorType) { 86 | case 'class': 87 | return `${prefix}.${selector}`; 88 | case 'id': 89 | return `${prefix}#${selector}`; 90 | case 'attr': 91 | return `${prefix}[${selector}]`; 92 | default: 93 | return `${prefix}[${selector}]`; 94 | } 95 | }; 96 | 97 | export const handleComments = ({ newQuerySelector, oldQuerySelector, element, value, parent }) => { 98 | const newClosingComment = getClosingComment(newQuerySelector); 99 | const newOpeningComment = getOpeningComment(newQuerySelector); 100 | const nodes = parent.childNodes; 101 | 102 | removeExistingCommentElements({ element, nodes, oldQuerySelector, parent }); 103 | 104 | /** 105 | * Add new opening and closing comment elements 106 | */ 107 | if (value && value.length) { 108 | const commentAfter = document.createComment(newClosingComment); 109 | const commentBefore = document.createComment(newOpeningComment); 110 | 111 | parent.insertBefore(commentBefore, element); 112 | parent.insertBefore(commentAfter, element.nextSibling); 113 | } 114 | }; 115 | 116 | export const handleMountedAndUpdated = async (element, { instance, arg, value, oldValue }, vnode) => { 117 | if (!inCorrectEnvironment()) { 118 | if (element.hubbleMouseover) { 119 | document.removeEventListener('mouseover', element.hubbleMouseover); 120 | element.hubbleMouseover = undefined; 121 | } 122 | 123 | return; 124 | } 125 | 126 | if (!element.hubbleMouseover) { 127 | const id = Math.random().toString(36).substr(2, 11); 128 | 129 | element.hubbleMouseover = handleMouseover(instance, element, id); 130 | document.addEventListener('mouseover', element.hubbleMouseover); 131 | } 132 | 133 | arg = arg || $hubble.defaultSelectorType; 134 | 135 | const parent = element.parentElement; 136 | const newSelector = getGenericSelector(instance, vnode, value); 137 | const oldSelector = getGenericSelector(instance, vnode, oldValue); 138 | const newQuerySelector = getQuerySelector(newSelector, arg, instance); 139 | const oldQuerySelector = getQuerySelector(oldSelector, arg, instance); 140 | 141 | if ($hubble.enableComments && parent) { 142 | handleComments({ element, newQuerySelector, oldQuerySelector, parent, value }); 143 | } else if (parent) { 144 | const nodes = parent.childNodes; 145 | 146 | removeExistingCommentElements({ element, nodes, oldQuerySelector, parent }); 147 | } 148 | 149 | handleNamespaceAttribute({ element, newQuerySelector, newSelector, oldSelector }); 150 | handleHubbleSelector({ 151 | arg, 152 | element, 153 | newQuerySelector, 154 | newSelector, 155 | oldSelector, 156 | }); 157 | }; 158 | 159 | export const handleHubbleSelector = ({ arg, element, oldSelector, newSelector, newQuerySelector }) => { 160 | switch (arg) { 161 | case 'class': 162 | oldSelector && element.classList.remove(oldSelector); 163 | if (newSelector) { 164 | element.classList.add(newSelector); 165 | element.setAttribute(`${NAMESPACE}-selector`, newQuerySelector); 166 | } 167 | break; 168 | 169 | case 'id': 170 | element.id = newSelector; 171 | break; 172 | 173 | case 'attr': 174 | oldSelector && element.removeAttribute(oldSelector); 175 | if (newSelector) { 176 | element.setAttributeNode(element.ownerDocument.createAttribute(newSelector)); 177 | } 178 | break; 179 | 180 | default: 181 | console.warn(`${arg} is not a valid selector type, using attr instead`); 182 | oldSelector && element.removeAttribute(oldSelector); 183 | if (newSelector) { 184 | element.setAttributeNode(element.ownerDocument.createAttribute(newSelector)); 185 | } 186 | break; 187 | } 188 | }; 189 | 190 | export const handleNamespaceAttribute = ({ element, oldSelector, newSelector, newQuerySelector }) => { 191 | oldSelector && element.removeAttribute(NAMESPACE); 192 | element.setAttributeNode(element.ownerDocument.createAttribute(NAMESPACE)); 193 | element.setAttribute(`${NAMESPACE}-selector`, newSelector ? newQuerySelector : ''); 194 | }; 195 | 196 | export const removeExistingCommentElements = ({ nodes, element, parent, oldQuerySelector }) => { 197 | const oldClosingComment = getClosingComment(oldQuerySelector); 198 | const oldOpeningComment = getOpeningComment(oldQuerySelector); 199 | 200 | for (let i = 0; i < nodes.length; i++) { 201 | const nextSibling = nodes[i + 1]; 202 | const prevSibling = nodes[i - 1]; 203 | 204 | if ( 205 | nodes[i] === element && 206 | nextSibling && 207 | nextSibling.nodeType === 8 && 208 | nextSibling.textContent === oldClosingComment 209 | ) { 210 | parent.removeChild(nextSibling); 211 | } 212 | 213 | if ( 214 | nodes[i] === element && 215 | prevSibling && 216 | prevSibling.nodeType === 8 && 217 | prevSibling.textContent === oldOpeningComment 218 | ) { 219 | parent.removeChild(prevSibling); 220 | } 221 | } 222 | }; 223 | 224 | export const getTooltip = (selector) => { 225 | return `'${selector}'`; 226 | }; 227 | 228 | export const addTooltip = (target, id) => { 229 | const { top, left, width } = target.getBoundingClientRect(); 230 | const selector = target.getAttribute(`${NAMESPACE}-selector`); 231 | const text = getTooltip(selector); 232 | const tooltip = document.createElement('span'); 233 | 234 | tooltip.style.position = 'fixed'; 235 | tooltip.style.padding = '6px'; 236 | tooltip.style.background = '#374151'; 237 | tooltip.style.borderRadius = '2px'; 238 | tooltip.style.boxShadow = '0 10px 15px -3px rgba(0,0,0,0.1),0 4px 6px -2px rgba(0,0,0,0.05)'; 239 | tooltip.style.color = '#A5B4FC'; 240 | tooltip.style.fontWeight = '400'; 241 | tooltip.style.userSelect = 'all'; 242 | tooltip.style.zIndex = '99999999'; 243 | tooltip.style.cursor = 'pointer'; 244 | tooltip.style.fontSize = '16px'; 245 | tooltip.style.fontFamily = 'monospace'; 246 | tooltip.style.whiteSpace = 'nowrap'; 247 | tooltip.style.textAlign = 'center'; 248 | tooltip.innerText = text; 249 | tooltip.setAttribute(`${NAMESPACE}-tooltip-id`, id); 250 | tooltip.setAttributeNode(tooltip.ownerDocument.createAttribute(`${NAMESPACE}-tooltip`)); 251 | 252 | document.body.appendChild(tooltip); 253 | 254 | tooltip.style.width = `${tooltip.offsetWidth}px`; 255 | tooltip.style.left = `${Math.min( 256 | window.innerWidth - tooltip.offsetWidth, 257 | Math.max(0, left + width / 2 - tooltip.offsetWidth / 2) 258 | )}px`; 259 | tooltip.style.top = `${Math.min( 260 | window.outerHeight - tooltip.offsetHeight, 261 | Math.max(0, top - tooltip.offsetHeight) 262 | )}px`; 263 | 264 | tooltip.addEventListener('click', () => { 265 | document.execCommand('copy'); 266 | tooltip.innerText = 'Copied!'; 267 | setTimeout(() => { 268 | tooltip.innerText = text; 269 | }, COPY_MESSAGE_RESET_TIMEOUT); 270 | }); 271 | }; 272 | 273 | export const addHighlight = (target, id) => { 274 | const highlight = document.createElement('div'); 275 | const { top, left, height, width } = target.getBoundingClientRect(); 276 | 277 | highlight.style.position = 'fixed'; 278 | highlight.style.width = `${width}px`; 279 | highlight.style.height = `${height}px`; 280 | highlight.style.left = `${left}px`; 281 | highlight.style.top = `${top}px`; 282 | highlight.style.pointerEvents = 'none'; 283 | highlight.style.zIndex = '99999998'; 284 | highlight.style.background = 'rgba(99, 102, 241, .1)'; 285 | highlight.style.border = '2px solid #6366F1'; 286 | highlight.setAttribute(`${NAMESPACE}-highlight-id`, id); 287 | 288 | document.body.appendChild(highlight); 289 | }; 290 | 291 | export const handleMouseover = (instance, element, id) => (event) => { 292 | const { target } = event; 293 | const oldTooltip = document.querySelector(`[${NAMESPACE}-tooltip-id="${id}"]`); 294 | const oldHighlight = document.querySelector(`[${NAMESPACE}-highlight-id="${id}"]`); 295 | const shouldRender = target === element || target === oldTooltip || element.contains(target); 296 | 297 | if (!shouldRender || !selectorPickerEnabled()) { 298 | oldTooltip && oldTooltip.remove(); 299 | 300 | return oldHighlight && oldHighlight.remove(); 301 | } 302 | 303 | if (oldTooltip) return; 304 | 305 | addTooltip(element, id, instance); 306 | addHighlight(element, id); 307 | }; 308 | 309 | export const handleCreated = async (element, { instance }, vnode) => { 310 | !instance.hubbleUnwatch && 311 | (instance.hubbleUnwatch = watch( 312 | $hubble, 313 | function () { 314 | instance.$forceUpdate(); 315 | }, 316 | { deep: true } 317 | )); 318 | 319 | if (!inCorrectEnvironment()) return; 320 | 321 | const id = Math.random().toString(36).substr(2, 11); 322 | 323 | element.hubbleMouseover = handleMouseover(instance, element, id); 324 | 325 | document.addEventListener('mouseover', element.hubbleMouseover); 326 | }; 327 | 328 | export const handleUnmounted = (element, { instance }) => { 329 | element.hubbleMouseover && document.removeEventListener('mouseover', element.hubbleMouseover); 330 | instance.hubbleUnwatch && instance.hubbleUnwatch(); 331 | }; 332 | 333 | export default ($h) => { 334 | $hubble = $h; 335 | 336 | return { 337 | created: handleCreated, 338 | mounted: handleMountedAndUpdated, 339 | unmounted: handleUnmounted, 340 | updated: handleMountedAndUpdated, 341 | }; 342 | }; 343 | -------------------------------------------------------------------------------- /plugin/src/directive.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | 3 | import VueHubble, { defaultConfig } from '.'; 4 | import { 5 | get, 6 | getClosingComment, 7 | getOpeningComment, 8 | getGenericSelector, 9 | getQuerySelector, 10 | NAMESPACE, 11 | ENV_WILDCARD, 12 | } from './directive'; 13 | 14 | jest.useFakeTimers(); 15 | 16 | const getWrapper = ( 17 | { mountOptions = {}, hubbleOptions = {}, overrides = {}, selector = 'selector' } = { 18 | hubbleOptions: {}, 19 | mountOptions: {}, 20 | overrides: {}, 21 | selector: 'selector', 22 | } 23 | ) => { 24 | global.console.warn = jest.fn(); 25 | 26 | const wrapper = mount( 27 | { 28 | data() { 29 | return { 30 | selector, 31 | }; 32 | }, 33 | template: `
`, 34 | ...overrides, 35 | }, 36 | { 37 | ...mountOptions, 38 | global: { 39 | ...(mountOptions.global || {}), 40 | plugins: [ 41 | [ 42 | VueHubble, 43 | { 44 | ...defaultConfig, 45 | ...hubbleOptions, 46 | }, 47 | ], 48 | ], 49 | }, 50 | } 51 | ); 52 | 53 | return wrapper; 54 | }; 55 | 56 | describe('directive.js', () => { 57 | beforeEach(() => { 58 | process.env.NODE_ENV = 'test'; 59 | }); 60 | 61 | it(`should add a the ${NAMESPACE} attribute`, () => { 62 | const wrapper = getWrapper(); 63 | 64 | expect(wrapper.find(`[${NAMESPACE}]`).exists()).toBe(true); 65 | }); 66 | 67 | it('should add an attribute selector', () => { 68 | const wrapper = getWrapper(); 69 | 70 | expect(wrapper.find(`[${NAMESPACE}][selector]`).exists()).toBe(true); 71 | }); 72 | 73 | it('should add a class selector', () => { 74 | const wrapper = getWrapper({ hubbleOptions: { defaultSelectorType: 'class' } }); 75 | 76 | expect(wrapper.find(`[${NAMESPACE}].selector`).exists()).toBe(true); 77 | }); 78 | 79 | it('should add an id selector', () => { 80 | const wrapper = getWrapper({ hubbleOptions: { defaultSelectorType: 'id' } }); 81 | 82 | expect(wrapper.find(`[${NAMESPACE}]#selector`).exists()).toBe(true); 83 | }); 84 | 85 | it('should add a selector if NODE_ENV is not test but environment includes wildcard', () => { 86 | process.env.NODE_ENV = 'not-test'; 87 | 88 | const wrapper = getWrapper({ hubbleOptions: { environment: [ENV_WILDCARD] } }); 89 | 90 | expect(wrapper.find(`[${NAMESPACE}][selector]`).exists()).toBe(true); 91 | }); 92 | 93 | it('should not add a selector if NODE_ENV is not test', () => { 94 | process.env.NODE_ENV = 'not-test'; 95 | 96 | const wrapper = getWrapper(); 97 | 98 | expect(wrapper.find(`[${NAMESPACE}]#selector`).exists()).toBe(false); 99 | }); 100 | 101 | it('should use component tree to namespace the selector', () => { 102 | const wrapper = getWrapper({ 103 | mountOptions: { 104 | global: { 105 | stubs: { 106 | child: { 107 | hubble: 'child', 108 | template: '
', 109 | }, 110 | }, 111 | }, 112 | }, 113 | overrides: { 114 | hubble: { 115 | namespace: 'parent', 116 | }, 117 | template: '
', 118 | }, 119 | }); 120 | 121 | expect(wrapper.find(`[${NAMESPACE}][parent--child--selector]`).exists()).toBe(true); 122 | }); 123 | 124 | it('should use component tree to namespace the selector and skip empty namespaces', () => { 125 | const wrapper = getWrapper({ 126 | mountOptions: { 127 | global: { 128 | stubs: { 129 | child: { 130 | template: '
', 131 | }, 132 | }, 133 | }, 134 | }, 135 | overrides: { 136 | hubble: { 137 | namespace: 'parent', 138 | }, 139 | template: '
', 140 | }, 141 | }); 142 | 143 | expect(wrapper.find(`[${NAMESPACE}][parent--selector]`).exists()).toBe(true); 144 | }); 145 | 146 | it('should handle reactive attr selectors', async () => { 147 | const prefix = 'qa'; 148 | const value = 'selector'; 149 | 150 | const wrapper = getWrapper({ 151 | hubbleOptions: { defaultSelectorType: 'attr', enableComments: true, prefix }, 152 | selector: value, 153 | }); 154 | 155 | const selector = getGenericSelector(wrapper.vm, wrapper.vm.$el.__vnode, value); 156 | const querySelector = getQuerySelector(selector, 'attr', wrapper.vm); 157 | const closingComment = getClosingComment(querySelector); 158 | const openingComment = getOpeningComment(querySelector); 159 | 160 | expect(wrapper.find(`[${NAMESPACE}][${selector}]`).exists()).toBe(true); 161 | expect(wrapper.html().indexOf(``)).toBeGreaterThan(-1); 162 | expect(wrapper.html().indexOf(``)).toBeGreaterThan(-1); 163 | 164 | wrapper.setData({ 165 | selector: '', 166 | }); 167 | 168 | await wrapper.vm.$nextTick(); 169 | expect(wrapper.find(`[${NAMESPACE}][${selector}]`).exists()).toBe(false); 170 | expect(wrapper.html().indexOf(``)).toBe(-1); 171 | expect(wrapper.html().indexOf(``)).toBe(-1); 172 | }); 173 | 174 | it('should handle reactive class selectors', async () => { 175 | const wrapper = getWrapper({ hubbleOptions: { defaultSelectorType: 'class' }, selector: 'old' }); 176 | expect(wrapper.find(`[${NAMESPACE}].old`).exists()).toBe(true); 177 | 178 | wrapper.setData({ 179 | selector: 'new', 180 | }); 181 | 182 | await wrapper.vm.$nextTick(); 183 | expect(wrapper.find(`[${NAMESPACE}].new`).exists()).toBe(true); 184 | }); 185 | 186 | it('should handle reactive class selectors starting empty', async () => { 187 | const wrapper = getWrapper({ hubbleOptions: { defaultSelectorType: 'class' }, selector: '' }); 188 | 189 | expect(wrapper.find(`[${NAMESPACE}].new`).exists()).toBe(false); 190 | 191 | wrapper.setData({ 192 | selector: 'new', 193 | }); 194 | 195 | await wrapper.vm.$nextTick(); 196 | expect(wrapper.find(`[${NAMESPACE}].new`).exists()).toBe(true); 197 | }); 198 | 199 | it('should handle reactive invalid selectors starting empty', async () => { 200 | const wrapper = getWrapper({ hubbleOptions: { defaultSelectorType: 'invalid' }, selector: '' }); 201 | 202 | expect(wrapper.find(`[${NAMESPACE}].new`).exists()).toBe(false); 203 | expect(global.console.warn).toHaveBeenCalledWith('invalid is not a valid selector type, using attr instead'); 204 | 205 | wrapper.setData({ 206 | selector: 'new', 207 | }); 208 | 209 | await wrapper.vm.$nextTick(); 210 | expect(wrapper.find(`[${NAMESPACE}].new`).exists()).toBe(false); 211 | }); 212 | 213 | it('should handle reactive invalid selectors', async () => { 214 | const wrapper = getWrapper({ hubbleOptions: { defaultSelectorType: 'invalid' }, selector: 'old' }); 215 | 216 | expect(wrapper.find(`[${NAMESPACE}].old`).exists()).toBe(false); 217 | expect(global.console.warn).toHaveBeenCalledWith('invalid is not a valid selector type, using attr instead'); 218 | 219 | wrapper.setData({ 220 | selector: 'new', 221 | }); 222 | 223 | await wrapper.vm.$nextTick(); 224 | expect(wrapper.find(`[${NAMESPACE}].new`).exists()).toBe(false); 225 | }); 226 | 227 | describe('get', () => { 228 | it('should correctly handle defaults', () => { 229 | expect(get({}, ['foo'], 'bar')).toBe('bar'); 230 | expect(get({}, ['foo'])).toBe(undefined); 231 | }); 232 | }); 233 | 234 | describe('selector picker', () => { 235 | it('should not render if enableSelectorPicker is false', async () => { 236 | const wrapper = getWrapper(); 237 | const element = wrapper.find(`[${NAMESPACE}][selector]`); 238 | 239 | const event = new MouseEvent('mouseover', { 240 | bubbles: true, 241 | cancelable: true, 242 | view: window, 243 | }); 244 | Object.defineProperty(event, 'target', { enumerable: true, value: element.element }); 245 | 246 | document.dispatchEvent(event); 247 | 248 | await wrapper.vm.$nextTick(); 249 | 250 | expect(document.querySelector(`[${NAMESPACE}-tooltip]`)).toBeNull(); 251 | }); 252 | 253 | it('should render if enableSelectorPicker is true', async () => { 254 | const wrapper = getWrapper({ 255 | hubbleOptions: { defaultSelectorType: undefined, enableSelectorPicker: true }, 256 | selector: undefined, 257 | }); 258 | 259 | const element = wrapper.find(`[${NAMESPACE}][selector]`); 260 | 261 | const event = new MouseEvent('mouseover', { 262 | bubbles: true, 263 | cancelable: true, 264 | view: window, 265 | }); 266 | Object.defineProperty(event, 'target', { enumerable: true, value: element.element }); 267 | document.dispatchEvent(event); 268 | await wrapper.vm.$nextTick(); 269 | const tooltip = document.querySelector(`[${NAMESPACE}-tooltip]`); 270 | expect(tooltip).toBeTruthy(); 271 | 272 | Object.defineProperty(event, 'target', { enumerable: true, value: element.element }); 273 | document.dispatchEvent(event); 274 | await wrapper.vm.$nextTick(); 275 | expect(document.querySelector(`[${NAMESPACE}-tooltip]`)).toEqual(tooltip); 276 | }); 277 | 278 | it('should render if a child is hovered over', async () => { 279 | const wrapper = getWrapper({ 280 | hubbleOptions: { defaultSelectorType: undefined, enableSelectorPicker: true }, 281 | selector: undefined, 282 | }); 283 | 284 | const element = wrapper.find(`[${NAMESPACE}][child]`); 285 | 286 | const event = new MouseEvent('mouseover', { 287 | bubbles: true, 288 | cancelable: true, 289 | view: window, 290 | }); 291 | Object.defineProperty(event, 'target', { enumerable: true, value: element.element }); 292 | 293 | document.dispatchEvent(event); 294 | 295 | await wrapper.vm.$nextTick(); 296 | 297 | expect(document.querySelector(`[${NAMESPACE}-tooltip]`)).toBeTruthy(); 298 | }); 299 | 300 | it('should copy the selector to clipboard', async () => { 301 | document.execCommand = jest.fn(); 302 | 303 | const wrapper = getWrapper({ 304 | hubbleOptions: { defaultSelectorType: undefined, enableSelectorPicker: true }, 305 | selector: undefined, 306 | }); 307 | 308 | const element = wrapper.find(`[${NAMESPACE}][selector]`); 309 | 310 | const event = new MouseEvent('mouseover', { 311 | bubbles: true, 312 | cancelable: true, 313 | view: window, 314 | }); 315 | Object.defineProperty(event, 'target', { enumerable: true, value: element.element }); 316 | 317 | document.dispatchEvent(event); 318 | 319 | await wrapper.vm.$nextTick(); 320 | 321 | const tooltip = document.querySelector(`[${NAMESPACE}-tooltip]`); 322 | 323 | tooltip.click(); 324 | expect(tooltip.innerText).toBe('Copied!'); 325 | 326 | jest.runAllTimers(); 327 | 328 | expect(tooltip.innerText).toBe(`'[${NAMESPACE}][selector]'`); 329 | expect(document.execCommand).toHaveBeenCalledWith('copy'); 330 | }); 331 | 332 | it('should remove event listeners', async () => { 333 | const wrapper = getWrapper({ 334 | hubbleOptions: { defaultSelectorType: 'attr', enableSelectorPicker: true }, 335 | selector: 'selector', 336 | }); 337 | 338 | wrapper.vm.$hubble.environment = 'woot'; 339 | 340 | jest.spyOn(document, 'removeEventListener'); 341 | await wrapper.vm.$forceUpdate(); 342 | 343 | wrapper.vm.$hubble.environment = defaultConfig.environment; 344 | 345 | expect(document.removeEventListener).toHaveBeenCalledWith('mouseover', expect.anything()); 346 | jest.spyOn(document, 'addEventListener'); 347 | 348 | await wrapper.vm.$forceUpdate(); 349 | 350 | expect(document.addEventListener).toHaveBeenCalledWith('mouseover', expect.anything()); 351 | }); 352 | 353 | it('should remove event listeners', () => { 354 | jest.spyOn(document, 'removeEventListener'); 355 | 356 | const wrapper = getWrapper({ 357 | hubbleOptions: { defaultSelectorType: 'attr', enableSelectorPicker: true }, 358 | selector: 'selector', 359 | }); 360 | 361 | wrapper.unmount(); 362 | 363 | expect(document.removeEventListener).toHaveBeenCalledWith('mouseover', expect.anything()); 364 | }); 365 | }); 366 | }); 367 | -------------------------------------------------------------------------------- /plugin/src/index.js: -------------------------------------------------------------------------------- 1 | import { reactive } from 'vue'; 2 | 3 | import api from './api'; 4 | import { default as directive } from './directive'; 5 | 6 | export const defaultConfig = { 7 | defaultSelectorType: 'attr', 8 | enableComments: false, 9 | enableDeepNamespacing: true, 10 | enableGroupedSelectors: true, 11 | enableSelectorPicker: false, 12 | environment: 'test', 13 | prefix: '', 14 | }; 15 | 16 | export default function install(vue, options = {}) { 17 | const merged = { ...defaultConfig, ...options }; 18 | merged.environment = [].concat(merged.environment); 19 | Object.defineProperty(merged, 'NODE_ENV', { value: process.env.NODE_ENV, writable: false }); 20 | 21 | let $hubble = reactive(merged); 22 | 23 | vue.mixin({ 24 | computed: { 25 | $hubble: { 26 | get() { 27 | return $hubble; 28 | }, 29 | }, 30 | }, 31 | }); 32 | 33 | window.$hubble = { 34 | ...api({ ...merged }), 35 | options: $hubble, 36 | }; 37 | 38 | vue.directive('hubble', directive($hubble)); 39 | } 40 | -------------------------------------------------------------------------------- /plugin/src/install.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | 3 | import { getClosingComment, getOpeningComment, getGenericSelector, NAMESPACE } from './directive'; 4 | import VueHubble, { defaultConfig } from '../src'; 5 | 6 | const getWrapper = ( 7 | { mountOptions = {}, hubbleOptions = {}, overrides = {}, selector = 'selector' } = { 8 | hubbleOptions: {}, 9 | mountOptions: {}, 10 | overrides: {}, 11 | selector: 'selector', 12 | } 13 | ) => { 14 | return mount( 15 | { 16 | hubble: { 17 | namespace: 'test', 18 | }, 19 | template: `
`, 20 | ...overrides, 21 | }, 22 | { 23 | ...mountOptions, 24 | global: { 25 | ...(mountOptions.global || {}), 26 | plugins: [ 27 | [ 28 | VueHubble, 29 | { 30 | ...defaultConfig, 31 | defaultSelectorType: 'class', 32 | environment: ['development', 'test'], 33 | ...hubbleOptions, 34 | }, 35 | ], 36 | ], 37 | }, 38 | } 39 | ); 40 | }; 41 | 42 | describe('install.js', () => { 43 | beforeEach(() => { 44 | global.console.warn = jest.fn(); 45 | process.env.NODE_ENV = 'test'; 46 | }); 47 | 48 | it('should allow the namespace to be set', () => { 49 | const wrapper = getWrapper(); 50 | 51 | expect(wrapper.find('.test--selector').exists()).toBe(true); 52 | }); 53 | 54 | it('should handle an invalid defaultSelectorType to be set', () => { 55 | const wrapper = getWrapper({ 56 | hubbleOptions: { defaultSelectorType: 'invalid' }, 57 | }); 58 | 59 | expect(wrapper.find('[test--selector]').exists()).toBe(true); 60 | expect(global.console.warn).toHaveBeenCalledWith('invalid is not a valid selector type, using attr instead'); 61 | }); 62 | 63 | it('should allow the defaultSelectorType to be set', () => { 64 | process.env.NODE_ENV = 'development'; 65 | 66 | const wrapper = getWrapper({ 67 | hubbleOptions: { defaultSelectorType: 'class', prefix: 'qa' }, 68 | }); 69 | 70 | expect(wrapper.find('.qa--test--selector').exists()).toBe(true); 71 | }); 72 | 73 | it('should allow the enableComments to be set to false', () => { 74 | let selector = 'selector'; 75 | const wrapper = getWrapper(); 76 | 77 | selector = getGenericSelector(wrapper.vm, wrapper.vm.$el.__vnode, selector); 78 | const closingComment = getClosingComment(selector); 79 | const openingComment = getOpeningComment(selector); 80 | 81 | expect(wrapper.find(`.${selector}`).exists()).toBe(true); 82 | expect(wrapper.html().indexOf(``)).toBe(-1); 83 | expect(wrapper.html().indexOf(``)).toBe(-1); 84 | }); 85 | 86 | it('should allow the enableDeepNamespacing to be set to false', () => { 87 | const wrapper = getWrapper({ 88 | hubbleOptions: { enableDeepNamespacing: false }, 89 | mountOptions: { 90 | global: { 91 | stubs: { 92 | child: { 93 | hubble: { 94 | namespace: 'child', 95 | }, 96 | template: '
', 97 | }, 98 | }, 99 | }, 100 | }, 101 | overrides: { 102 | hubble: { 103 | namespace: 'parent', 104 | }, 105 | template: '
', 106 | }, 107 | }); 108 | 109 | expect(wrapper.find('.parent--child--selector').exists()).toBe(false); 110 | expect(wrapper.find('.child--selector').exists()).toBe(true); 111 | }); 112 | 113 | it('should allow the enableDeepNamespacing to be set to true', () => { 114 | const wrapper = getWrapper({ 115 | mountOptions: { 116 | global: { 117 | stubs: { 118 | child: { 119 | hubble: { 120 | namespace: 'child', 121 | }, 122 | template: '
', 123 | }, 124 | }, 125 | }, 126 | }, 127 | overrides: { 128 | hubble: { 129 | namespace: 'parent', 130 | }, 131 | template: '
', 132 | }, 133 | }); 134 | 135 | expect(wrapper.find('.parent--child--selector').exists()).toBe(true); 136 | expect(wrapper.find('.child--selector').exists()).toBe(false); 137 | }); 138 | 139 | it('should properly prefix selectors', () => { 140 | const wrapper = getWrapper({ 141 | hubbleOptions: { prefix: 'qa' }, 142 | mountOptions: { 143 | global: { 144 | stubs: { 145 | child: { 146 | hubble: { 147 | namespace: 'child', 148 | }, 149 | template: '
', 150 | }, 151 | }, 152 | }, 153 | }, 154 | overrides: { 155 | hubble: { 156 | namespace: 'parent', 157 | }, 158 | template: '
', 159 | }, 160 | }); 161 | 162 | expect(wrapper.find('.qa--parent--child--selector').exists()).toBe(true); 163 | expect(wrapper.find('.qa--child--selector').exists()).toBe(false); 164 | }); 165 | 166 | it('should allow the enableGroupedSelectors to be set to true', () => { 167 | let selector = 'selector'; 168 | const wrapper = getWrapper(); 169 | 170 | selector = getGenericSelector(wrapper.vm, wrapper.vm.$el.__vnode, selector); 171 | 172 | expect(wrapper.find(`[${NAMESPACE}].${selector}`).attributes(`${NAMESPACE}-selector`)).toBe( 173 | `[${NAMESPACE}].${selector}` 174 | ); 175 | }); 176 | 177 | it('should allow the enableGroupedSelectors to be set to false', () => { 178 | let selector = 'selector'; 179 | const wrapper = getWrapper({ hubbleOptions: { enableGroupedSelectors: false } }); 180 | 181 | selector = getGenericSelector(wrapper.vm, wrapper.vm.$el.__vnode, selector); 182 | 183 | expect(wrapper.find(`.${selector}`).attributes(`${NAMESPACE}-selector`)).toBe(`.${selector}`); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "packageRules": [ 6 | { 7 | "depTypeList": [ 8 | "devDependencies" 9 | ], 10 | "automerge": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | entry: path.resolve(__dirname, 'plugin/src/index.js'), 8 | fileName: (format) => `vue-hubble.${format}.js`, 9 | name: 'VueHubble', 10 | }, 11 | rollupOptions: { 12 | external: ['vue'], 13 | }, 14 | sourcemap: true, 15 | }, 16 | plugins: [], 17 | }); 18 | --------------------------------------------------------------------------------