├── .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 |
5 |
6 |
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 |
2 |
3 |

4 |
5 |
6 |
7 |
8 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
21 |
22 |
27 |
--------------------------------------------------------------------------------
/demo/src/components/HelloWorld.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Hover over elements to see and copy selectors
4 | $ yarn serve --mode test
5 |
6 |
7 |
8 |
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 |
2 |
3 | {{ msg }}
4 |
5 |
6 |
7 |
16 |
--------------------------------------------------------------------------------
/docs/src/.vuepress/components/OtherComponent.vue:
--------------------------------------------------------------------------------
1 |
2 | This is another component
3 |
4 |
--------------------------------------------------------------------------------
/docs/src/.vuepress/components/demo-component.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ msg }}
4 |
5 |
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 |
2 |
3 |
4 | You are viewing the documentation for 4.x (Vue 3.x). For Vue 2.x head over to the
3.x documentation
8 |
9 |
10 |
11 |
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 |
23 |
24 |
25 |
26 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
45 |
46 |
47 | ```
48 |
49 | #### Composition API
50 |
51 | ```html
52 |
53 |
54 |
55 |
56 |
57 |
60 |
61 |
62 |
63 |
64 |
65 |
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 |
87 |
88 |
89 |
90 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
109 |
110 |
111 | ```
112 |
113 | #### Composition API
114 | ```html
115 |
116 |
117 |
118 |
119 |
120 |
123 |
124 |
125 |
126 |
127 |
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 | 
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
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 |
11 |
12 |
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 |
--------------------------------------------------------------------------------