├── .babelrc ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── Chrome-example.png ├── LICENSE ├── README.md ├── codecept.conf.js ├── demo.js ├── examples ├── playwright │ └── custom-engine.js ├── puppeteer │ ├── clicking-elements.js │ ├── custom-engine.js │ ├── multiple-elements.js │ └── typing-to-elements.js └── webdriverio │ ├── deeply-nested.js │ ├── multiple-elements.js │ └── typing-to-elements.js ├── jsconfig.json ├── karma.common.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── plugins ├── codeceptjs │ ├── README.md │ └── index.js ├── playwright │ └── index.js ├── protractor │ ├── index.d.ts │ └── index.js ├── puppeteer │ └── index.js └── webdriverio │ └── index.js ├── protractor.conf.js ├── puppeteer-es5.js ├── rollup.config.js ├── src ├── normalize.js └── querySelectorDeep.js ├── steps.d.ts ├── steps_file.js └── test ├── TestComponent.js ├── basic.spec.js ├── codeceptjs ├── README.md ├── codecept.conf.js ├── components.test.js ├── jsconfig.json ├── steps.d.ts └── steps_file.js ├── createTestComponent.js ├── index.html ├── nopolyfills.spec.js └── protractor-locator.e2e.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "browsers": ["last 2 versions", "IE >= 11"] 6 | } 7 | }] 8 | ], 9 | "comments" : false 10 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true, 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 12, 10 | "sourceType": "module" 11 | }, 12 | "globals": { 13 | "it": true, 14 | "describe": true, 15 | "expect": true, 16 | "beforeEach": true, 17 | "afterEach": true, 18 | "jasmine": true 19 | }, 20 | "rules": { "semi": ["error", "always"]} 21 | }; 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | ko_fi: griffadev 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 7 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 8 | liberapay: # Replace with a single Liberapay username 9 | issuehunt: # Replace with a single IssueHunt username 10 | otechie: # Replace with a single Otechie username 11 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: ['14', '16'] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js v${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install dependencies 23 | run: | 24 | npm ci 25 | - name: Build 26 | run: npm run build --if-present 27 | - name: Run Tests 28 | run: | 29 | npm run test:ci 30 | npm run e2e:protractor 31 | env: 32 | CHROMEDRIVER_FILEPATH: /usr/local/share/chromedriver-linux64/chromedriver 33 | 34 | timeout-minutes: 10 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs 2 | .vscode 3 | .idea 4 | 5 | # Build artifacts 6 | node_modules 7 | dist 8 | coverage 9 | .DS_Store 10 | **/output/** 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdriverio/query-selector-shadow-dom/5f5f446d839268d7440f49f3f4295e6437769017/.npmignore -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.21.3 2 | -------------------------------------------------------------------------------- /Chrome-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdriverio/query-selector-shadow-dom/5f5f446d839268d7440f49f3f4295e6437769017/Chrome-example.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 George Griffiths 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Georgegriff/query-selector-shadow-dom.svg?branch=main)](https://travis-ci.org/Georgegriff/query-selector-shadow-dom) [![npm version](https://badge.fury.io/js/query-selector-shadow-dom.svg)](https://badge.fury.io/js/query-selector-shadow-dom) 2 | 3 | # query-selector-shadow-dom 4 | 5 | querySelector that can pierce Shadow DOM roots without knowing the path through nested shadow roots. Useful for automated testing of Web Components e.g. with Selenium, Puppeteer. 6 | 7 | ```javascript 8 | // available as an ES6 module for importing in Browser environments 9 | 10 | import { 11 | querySelectorAllDeep, 12 | querySelectorDeep, 13 | } from "query-selector-shadow-dom"; 14 | ``` 15 | 16 | ## What is a nested shadow root? 17 | 18 | ![Image of Shadow DOM elements in dev tools](./Chrome-example.png) 19 | You can see that `.dropdown-item:not([hidden])` (Open downloads folder) is several layers deep in shadow roots, most tools will make you do something like 20 | 21 | ```javascript 22 | document 23 | .querySelector("body > downloads-manager") 24 | .shadowRoot.querySelector("#toolbar") 25 | .shadowRoot.querySelector(".dropdown-item:not([hidden])"); 26 | ``` 27 | 28 | EW! 29 | 30 | with query-selector-shadow-dom: 31 | 32 | ```javascript 33 | import { 34 | querySelectorAllDeep, 35 | querySelectorDeep, 36 | } from "query-selector-shadow-dom"; 37 | querySelectorDeep(".dropdown-item:not([hidden])"); 38 | ``` 39 | 40 | ## API 41 | 42 | - querySelectorAllDeep - mirrors `querySelectorAll` from the browser, will return an `Array` of elements matching the query 43 | - querySelectorDeep - mirrors `querySelector` from the browser, will return the `first` matching element of the query. 44 | - collectAllElementsDeep - collects all elements on the page, including shadow dom 45 | 46 | Both of the methods above accept a 2nd parameter, see section `Provide alternative node`. This will change the starting element to search from i.e. it will find ancestors of that node that match the query. 47 | 48 | ## Known limitations 49 | 50 | - Source ordering of results may not be preserved. Due to the nature of how this library works, by breaking down selectors into parts, when using multiple selectors (e.g. split by commas) the results will be based on the order of the query, not the order the result appear in the dom. This is different from the native `querySelectorAll` functionality. You can read more about this here: https://github.com/Georgegriff/query-selector-shadow-dom/issues/54 51 | 52 | ## Plugins 53 | 54 | ### WebdriverIO 55 | 56 | This plugin implements a custom selector strategy: https://webdriver.io/docs/selectors.html#custom-selector-strategies 57 | 58 | ```javascript 59 | // make sure you have selenium standalone running 60 | const { remote } = require("webdriverio"); 61 | const { 62 | locatorStrategy, 63 | } = require("query-selector-shadow-dom/plugins/webdriverio"); 64 | 65 | (async () => { 66 | const browser = await remote({ 67 | logLevel: "error", 68 | path: "/wd/hub", 69 | capabilities: { 70 | browserName: "chrome", 71 | }, 72 | }); 73 | 74 | // The magic - registry custom strategy 75 | browser.addLocatorStrategy("shadow", locatorStrategy); 76 | 77 | // now you have a `shadow` custom locator. 78 | 79 | // All elements on the page 80 | await browser.waitUntil(() => 81 | browser.custom$("shadow", ".btn-in-shadow-dom") 82 | ); 83 | const elements = await browser.$$("*"); 84 | 85 | const elementsShadow = await browser.custom$$("shadow", "*"); 86 | 87 | console.log("All Elements on Page Excluding Shadow Dom", elements.length); 88 | console.log( 89 | "All Elements on Page Including Shadow Dom", 90 | elementsShadow.length 91 | ); 92 | 93 | await browser.url("http://127.0.0.1:5500/test/"); 94 | // find input element in shadow dom 95 | const input = await browser.custom$("shadow", "#type-to-input"); 96 | // type to input ! Does not work in firefox, see above. 97 | await input.setValue("Typed text to input"); 98 | // Firefox workaround 99 | // await browser.execute((input, val) => input.value = val, input, 'Typed text to input') 100 | 101 | await browser.deleteSession(); 102 | })().catch((e) => console.error(e)); 103 | ``` 104 | 105 | #### How is this different to `shadow$` 106 | 107 | `shadow$` only goes one level deep in a shadow root. 108 | 109 | Take this example. 110 | ![Image of Shadow DOM elements in dev tools](./Chrome-example.png) 111 | You can see that `.dropdown-item:not([hidden])` (Open downloads folder) is several layers deep in shadow roots, but this library will find it, `shadow$` would not. 112 | You would have to construct a path via css or javascript all the way through to find the right element. 113 | 114 | ```javascript 115 | const { remote } = require("webdriverio"); 116 | const { 117 | locatorStrategy, 118 | } = require("query-selector-shadow-dom/plugins/webdriverio"); 119 | 120 | (async () => { 121 | const browser = await remote({ capabilities: { browserName: "chrome" } }); 122 | 123 | browser.addLocatorStrategy("shadow", locatorStrategy); 124 | 125 | await browser.url("chrome://downloads"); 126 | const moreActions = await browser.custom$("shadow", "#moreActions"); 127 | await moreActions.click(); 128 | const span = await browser.custom$("shadow", ".dropdown-item:not([hidden])"); 129 | const text = await span.getText(); 130 | // prints `Open downloads folder` 131 | console.log(text); 132 | 133 | await browser.deleteSession(); 134 | })().catch((e) => console.error(e)); 135 | ``` 136 | 137 | #### Known issues 138 | 139 | - https://webdriver.io/blog/2019/02/22/shadow-dom-support.html#browser-support 140 | 141 | - From the above, firefox `setValue` does NOT currently work. 142 | `. A workaround for now is to use a custom command (or method on your component object) that sets the input field's value via browser.execute(function).` 143 | 144 | - Safari pretty much doesn't work, not really a surprise. 145 | 146 | There are some webdriver examples available in the examples folder of this repository. 147 | [WebdriverIO examples](https://github.com/Georgegriff/query-selector-shadow-dom/blob/main/examples/webdriverio) 148 | 149 | ### Puppeteer 150 | 151 | Update: As of 5.4.0 Puppeteer now has a built in shadow Dom selector, this module might not be required for Puppeteer anymore. 152 | They don't have any documentation: https://github.com/puppeteer/puppeteer/pull/6509 153 | 154 | There are some puppeteer examples available in the examples folder of this repository. 155 | 156 | [Puppeteer examples](https://github.com/Georgegriff/query-selector-shadow-dom/blob/main/examples/puppeteer) 157 | 158 | ### Playwright 159 | 160 | Update: as of Playwright v0.14.0 their CSS and text selectors work with shadow Dom out of the box, you don't need this library anymore for Playwright. 161 | 162 | Playwright works really nicely with this package. 163 | 164 | This module exposes a playwright `selectorEngine`: https://github.com/microsoft/playwright/blob/main/docs/api.md#selectorsregisterenginefunction-args 165 | 166 | ```javascript 167 | const { selectorEngine } = require("query-selector-shadow-dom/plugins/playwright"); 168 | const playwright = require('playwright'); 169 | 170 | await selectors.register('shadow', createTagNameEngine); 171 | ... 172 | await page.goto('chrome://downloads'); 173 | // shadow= allows a css query selector that automatically pierces shadow roots. 174 | await page.waitForSelector('shadow=#no-downloads span', {timeout: 3000}) 175 | ``` 176 | 177 | For a full example see: https://github.com/Georgegriff/query-selector-shadow-dom/blob/main/examples/playwright 178 | 179 | ### Protractor 180 | 181 | This project provides a Protractor plugin, which can be enabled in your [`protractor.conf.js`](https://www.protractortest.org/#/api-overview) file: 182 | 183 | ```javascript 184 | exports.config = { 185 | plugins: [ 186 | { 187 | package: "query-selector-shadow-dom/plugins/protractor", 188 | }, 189 | ], 190 | 191 | // ... other Protractor-specific config 192 | }; 193 | ``` 194 | 195 | The plugin registers a new [locator](https://www.protractortest.org/#/api?view=ProtractorBy) - `by.shadowDomCss(selector /* string */)`, which can be used in regular Protractor tests: 196 | 197 | ```javascript 198 | element(by.shadowDomCss("#item-in-shadow-dom")); 199 | ``` 200 | 201 | The locator also works with [Serenity/JS](https://serenity-js.org) tests that [use Protractor](https://serenity-js.org/modules/protractor) under the hood: 202 | 203 | ```typescript 204 | import "query-selector-shadow-dom/plugins/protractor"; 205 | import { Target } from "@serenity-js/protractor"; 206 | import { by } from "protractor"; 207 | 208 | const ElementOfInterest = Target.the("element of interest").located( 209 | by.shadowDomCss("#item-in-shadow-dom") 210 | ); 211 | ``` 212 | 213 | See the [end-to-end tests](https://github.com/Georgegriff/query-selector-shadow-dom/blob/features/protractor-locator/test/protractor-locator.e2e.js) for more examples. 214 | 215 | ## Examples 216 | 217 | ### Provide alternative node 218 | 219 | ```javascript 220 | // query from another node 221 | querySelectorShadowDom.querySelectorAllDeep( 222 | "child", 223 | document.querySelector("#startNode") 224 | ); 225 | // query an iframe 226 | querySelectorShadowDom.querySelectorAllDeep("child", iframe.contentDocument); 227 | ``` 228 | 229 | This library does not allow you to query across iframe boundaries, you will need to get a reference to the iframe you want to interact with.
230 | If your iframe is inside of a shadow root you could use `querySelectorDeep` to find the iframe, then pass the `contentDocument` into the 2nd argument of `querySelectorDeep` or `querySelectorAllDeep`. 231 | 232 | ### Chrome downloads page 233 | 234 | In the below examples the components being searched for are nested within web components `shadowRoots`. 235 | 236 | ```javascript 237 | // Download and Paste the lib code in dist into chrome://downloads console to try it out :) 238 | 239 | console.log( 240 | querySelectorShadowDom.querySelectorAllDeep( 241 | "downloads-item:nth-child(4) #remove" 242 | ) 243 | ); 244 | console.log( 245 | querySelectorShadowDom.querySelectorAllDeep( 246 | '#downloads-list .is-active a[href^="https://"]' 247 | ) 248 | ); 249 | console.log( 250 | querySelectorShadowDom.querySelectorDeep("#downloads-list div#title-area + a") 251 | ); 252 | ``` 253 | 254 | # Shady DOM 255 | 256 | If using the polyfills and shady DOM, this library will still work. 257 | 258 | ## Importing 259 | 260 | - Shipped as an ES6 module to be included using a bundler of your choice (or not). 261 | - ES5 version bundled on top the window as `window.querySelectorShadowDom` available for easy include into a test framework 262 | 263 | ## Running the code locally 264 | 265 | `npm install` 266 | 267 | ### Running the tests 268 | 269 | `npm test` 270 | 271 | ### Running the tests in watch mode 272 | 273 | `npm run watch` 274 | 275 | ### Running the build 276 | 277 | `npm run build` 278 | -------------------------------------------------------------------------------- /codecept.conf.js: -------------------------------------------------------------------------------- 1 | const { setHeadlessWhen } = require('@codeceptjs/configure'); 2 | 3 | // turn on headless mode when running with HEADLESS=true environment variable 4 | // HEADLESS=true npx codecept run 5 | setHeadlessWhen(process.env.HEADLESS); 6 | 7 | exports.config = { 8 | tests: 'test/codeceptjs/*.test.js', 9 | output: './output', 10 | helpers: { 11 | Playwright: { 12 | url: 'http://localhost', 13 | show: true, 14 | browser: 'chromium' 15 | } 16 | }, 17 | include: { 18 | I: './steps_file.js' 19 | }, 20 | bootstrap: null, 21 | mocha: {}, 22 | name: 'query-selector-shadow-dom', 23 | plugins: { 24 | retryFailedStep: { 25 | enabled: true 26 | }, 27 | screenshotOnFail: { 28 | enabled: true 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /demo.js: -------------------------------------------------------------------------------- 1 | // Paste this code into chrome://downloads console to try it out :) 2 | 3 | console.log(querySelectorDeep('downloads-item:nth-child(4) #remove')); 4 | console.log(querySelectorDeep('#downloads-list .is-active a[href^="https://"]')); 5 | console.log(querySelectorDeep('#downloads-list div#title-area + a')); -------------------------------------------------------------------------------- /examples/playwright/custom-engine.js: -------------------------------------------------------------------------------- 1 | const { selectorEngine } = require("query-selector-shadow-dom/plugins/playwright"); 2 | const playwright = require('playwright') 3 | 4 | const main = async () => { 5 | await playwright.selectors.register('shadow', selectorEngine) 6 | 7 | const browser = await playwright.chromium.launch({ headless: false}) 8 | const context = await browser.newContext({ viewport: null }) 9 | const page = await context.newPage() 10 | 11 | await page.goto('chrome://downloads') 12 | 13 | await page.waitForSelector('shadow=#no-downloads span', {timeout: 3000}) 14 | await new Promise(resolve => setTimeout(resolve, 3000)) 15 | 16 | await page.close() 17 | await context.close() 18 | await browser.close() 19 | } 20 | 21 | main() -------------------------------------------------------------------------------- /examples/puppeteer/clicking-elements.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { QueryHandler } = require("query-selector-shadow-dom/plugins/puppeteer"); 3 | (async () => { 4 | try { 5 | await puppeteer.registerCustomQueryHandler('shadow', QueryHandler); 6 | const browser = await puppeteer.launch({ 7 | headless: false, 8 | devtools: true 9 | }) 10 | const page = await browser.newPage() 11 | await page.goto('http://127.0.0.1:5500/test/') 12 | 13 | // ensure btn exists and return it 14 | await page.waitForSelector("shadow/.btn-in-shadow-dom"); 15 | const btn = await page.$("shadow/.btn-in-shadow-dom"); 16 | await btn.click(); 17 | // check btn was clicked (this page expected btn to change text of output) 18 | const outputSpan = await page.$("shadow/.output"); 19 | const text = await page.evaluate((output) => output.innerText, outputSpan); 20 | // prints the text from the output 21 | console.log(text); 22 | 23 | await browser.close() 24 | } catch (e) { 25 | console.error(e); 26 | } 27 | 28 | })() 29 | -------------------------------------------------------------------------------- /examples/puppeteer/custom-engine.js: -------------------------------------------------------------------------------- 1 | const { QueryHandler } = require("query-selector-shadow-dom/plugins/puppeteer"); 2 | const puppeteer = require('puppeteer'); 3 | 4 | const main = async () => { 5 | await puppeteer.registerCustomQueryHandler('shadow', QueryHandler); 6 | 7 | const browser = await puppeteer.chromium.launch({ headless: false}); 8 | const context = await browser.newContext({ viewport: null }); 9 | const page = await context.newPage(); 10 | 11 | await page.goto('chrome://downloads'); 12 | 13 | const element = await page.waitForSelector('shadow/div', {timeout: 3000}); 14 | const span = await element.$$("shadow/div > .illustration + span"); 15 | console.log(span); 16 | await new Promise(resolve => setTimeout(resolve, 3000)); 17 | 18 | await page.close() 19 | await context.close() 20 | await browser.close() 21 | } 22 | 23 | main() 24 | -------------------------------------------------------------------------------- /examples/puppeteer/multiple-elements.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { QueryHandler } = require("query-selector-shadow-dom/plugins/puppeteer"); 3 | (async () => { 4 | try { 5 | await puppeteer.registerCustomQueryHandler('shadow', QueryHandler); 6 | const browser = await puppeteer.launch({ 7 | headless: false 8 | }) 9 | const page = await browser.newPage() 10 | await page.goto('http://127.0.0.1:5500/test/') 11 | // wait for a web component to appear 12 | await page.waitForSelector("shadow/.btn-in-shadow-dom") 13 | const elements = await page.$$("*"); 14 | const elementsShadow = await page.$$("shadow/*"); 15 | console.log("All Elements on Page Excluding Shadow Dom", elements.length); 16 | console.log("All Elements on Page Including Shadow Dom", elementsShadow.length); 17 | await browser.close() 18 | 19 | } catch (e) { 20 | console.error(e); 21 | } 22 | })() 23 | -------------------------------------------------------------------------------- /examples/puppeteer/typing-to-elements.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { QueryHandler } = require("query-selector-shadow-dom/plugins/puppeteer"); 3 | (async () => { 4 | try { 5 | await puppeteer.registerCustomQueryHandler('shadow', QueryHandler); 6 | const browser = await puppeteer.launch({ 7 | headless: false 8 | }) 9 | const page = await browser.newPage() 10 | await page.goto('http://127.0.0.1:5500/test/') 11 | 12 | const inputElement = await page.waitForSelector("shadow/#type-to-input"); 13 | 14 | await inputElement.type("Typed text to input"); 15 | 16 | const value = await page.evaluate(inputElement => inputElement.value, inputElement); 17 | console.log("Value", value); 18 | 19 | await browser.close() 20 | 21 | } catch (e) { 22 | console.error(e); 23 | } 24 | 25 | })() 26 | -------------------------------------------------------------------------------- /examples/webdriverio/deeply-nested.js: -------------------------------------------------------------------------------- 1 | const { remote } = require('webdriverio') 2 | const { locatorStrategy } = require('query-selector-shadow-dom/plugins/webdriverio'); 3 | 4 | ;(async () => { 5 | const browser = await remote({ 6 | logLevel: 'error', 7 | path: '/wd/hub', 8 | capabilities: { 9 | browserName: 'chrome' 10 | } 11 | }) 12 | 13 | browser.addLocatorStrategy('shadow', locatorStrategy); 14 | 15 | 16 | await browser.url('chrome://downloads') 17 | const moreActions = await browser.custom$('shadow', '#moreActions'); 18 | await moreActions.click(); 19 | const span = await browser.custom$('shadow', '.dropdown-item:not([hidden])'); 20 | const text = await span.getText() 21 | // prints `Open downloads folder` 22 | console.log(text); 23 | 24 | await browser.deleteSession() 25 | })().catch((e) => console.error(e)) -------------------------------------------------------------------------------- /examples/webdriverio/multiple-elements.js: -------------------------------------------------------------------------------- 1 | const { remote } = require('webdriverio') 2 | const { locatorStrategy } = require('query-selector-shadow-dom/plugins/webdriverio'); 3 | 4 | ;(async () => { 5 | const browser = await remote({ 6 | logLevel: 'error', 7 | path: '/wd/hub', 8 | capabilities: { 9 | browserName: 'firefox' 10 | } 11 | }) 12 | 13 | browser.addLocatorStrategy('shadow', locatorStrategy); 14 | 15 | 16 | await browser.url('http://127.0.0.1:5500/test/') 17 | await browser.waitUntil(() => browser.custom$("shadow", ".btn-in-shadow-dom")); 18 | const elements = await browser.$$("*"); 19 | const elementsShadow = await browser.custom$$("shadow", "*"); 20 | console.log("All Elements on Page Excluding Shadow Dom", elements.length); 21 | console.log("All Elements on Page Including Shadow Dom", elementsShadow.length); 22 | 23 | await browser.deleteSession() 24 | })().catch((e) => console.error(e)) -------------------------------------------------------------------------------- /examples/webdriverio/typing-to-elements.js: -------------------------------------------------------------------------------- 1 | const { remote } = require('webdriverio') 2 | const { locatorStrategy } = require('query-selector-shadow-dom/plugins/webdriverio'); 3 | 4 | ;(async () => { 5 | const browser = await remote({ 6 | logLevel: 'error', 7 | path: '/wd/hub', 8 | capabilities: { 9 | browserName: 'firefox' 10 | } 11 | }) 12 | 13 | browser.addLocatorStrategy('shadow', locatorStrategy); 14 | 15 | 16 | await browser.url('http://127.0.0.1:5500/test/') 17 | const input = await browser.custom$('shadow', '#type-to-input'); 18 | await input.setValue('Typed text to input'); 19 | // Firefox workaround 20 | // await browser.execute((input, val) => input.value = val, input, 'Typed text to input') 21 | console.log(await input.getValue()) 22 | 23 | await browser.deleteSession() 24 | })().catch((e) => console.error(e)) -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true 4 | } 5 | } -------------------------------------------------------------------------------- /karma.common.js: -------------------------------------------------------------------------------- 1 | const babel = require('rollup-plugin-babel'); 2 | const babelrc = require('babelrc-rollup').default; 3 | const istanbul = require('rollup-plugin-istanbul'); 4 | 5 | const babelConfig = { 6 | 'presets': [ 7 | ['@babel/preset-env', { 8 | 'targets': { 9 | 'browsers': ['ff >= 60'] 10 | }, 11 | 'loose': true 12 | }] 13 | ] 14 | }; 15 | 16 | module.exports = function(overrides, config) { 17 | return Object.assign({ 18 | // base path that will be used to resolve all patterns (eg. files, exclude) 19 | basePath: '', 20 | 21 | 22 | // frameworks to use 23 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 24 | frameworks: ['jasmine'], 25 | 26 | 27 | // list of files / patterns to load in the browser 28 | files: [ 29 | { pattern: 'node_modules/@webcomponents/webcomponentsjs/bundles/**.js', served: true, included: true }, 30 | { pattern: 'node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js', served: true, included: true }, 31 | 'test/**/*.spec.js' 32 | ], 33 | 34 | 35 | // list of files / patterns to exclude 36 | exclude: [], 37 | 38 | 39 | // preprocess matching files before serving them to the browser 40 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 41 | preprocessors: { 42 | 'src/**/*.js': ['rollup', 'coverage'], 43 | 'test/**/*.js': ['rollup'] 44 | }, 45 | 46 | rollupPreprocessor: { 47 | /** 48 | * This is just a normal Rollup config object, 49 | * except that `input` is handled for you. 50 | */ 51 | plugins: [ 52 | istanbul({ 53 | exclude: ['test/**/*.js'] 54 | }), 55 | babel(babelrc({ 56 | addExternalHelpersPlugin: false, 57 | config: babelConfig, 58 | exclude: 'node_modules/**' 59 | })) 60 | 61 | ], 62 | output: { 63 | format: 'iife', // Helps prevent naming collisions. 64 | name: 'querySelectorShadowDom', // Required for 'iife' format. 65 | sourcemap: 'inline' // Sensible for testing. 66 | } 67 | }, 68 | 69 | // test results reporter to use 70 | // possible values: 'dots', 'progress' 71 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 72 | reporters: ['spec', 'coverage'], 73 | specReporter: { 74 | maxLogLines: 5, // limit number of lines logged per test 75 | suppressErrorSummary: true, // do not print error summary 76 | suppressFailed: false, // do not print information about failed tests 77 | suppressPassed: false, // do not print information about passed tests 78 | suppressSkipped: true, // do not print information about skipped tests 79 | showSpecTiming: true, // print the time elapsed for each spec 80 | failFast: true // test would finish with error when a first fail occurs. 81 | }, 82 | coverageReporter: { 83 | type: 'lcov', // lcov or lcovonly are required for generating lcov.info files 84 | dir: 'coverage/' 85 | }, 86 | // web server port 87 | port: 9876, 88 | 89 | 90 | // enable / disable colors in the output (reporters and logs) 91 | colors: true, 92 | 93 | 94 | // level of logging 95 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 96 | logLevel: config.LOG_INFO, 97 | 98 | 99 | // enable / disable watching file and executing tests whenever any file changes 100 | autoWatch: true, 101 | 102 | 103 | // Continuous Integration mode 104 | // if true, Karma captures browsers, runs the tests and exits 105 | singleRun: true, 106 | 107 | // Concurrency level 108 | // how many browser should be started simultaneous 109 | concurrency: Infinity 110 | }, overrides); 111 | }; -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const KarmaConfig = require('./karma.common.js'); 2 | module.exports = function(config) { 3 | 4 | config.set(KarmaConfig({ 5 | browsers: ['ChromeHeadless', 'Firefox'] 6 | }, config)); 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-selector-shadow-dom", 3 | "version": "1.0.1", 4 | "description": "use querySelector syntax to search for nodes inside of (nested) shadow roots", 5 | "main": "src/querySelectorDeep.js", 6 | "scripts": { 7 | "prepublish": "npm run build", 8 | "prebuild": "npm run lint", 9 | "lint": "eslint src/**/*.js", 10 | "build": "rollup -c", 11 | "test": "karma start", 12 | "test:ci": "karma start --browsers ChromeHeadless,FirefoxHeadless", 13 | "e2e:protractor": "protractor protractor.conf.js", 14 | "watch": "npm-watch", 15 | "selenium": "./node_modules/.bin/selenium-standalone install && ./node_modules/.bin/selenium-standalone start" 16 | }, 17 | "watch": { 18 | "test": "{src,test}/*.js" 19 | }, 20 | "files": [ 21 | "Chrome-example.png", 22 | "/src/", 23 | "dist/querySelectorShadowDom.js", 24 | "/plugins/" 25 | ], 26 | "author": "George Griffiths ", 27 | "keywords": [ 28 | "webcomponents", 29 | "puppeteer", 30 | "playwright", 31 | "automation", 32 | "queryselector", 33 | "shadowdom", 34 | "web-components", 35 | "testing", 36 | "webdriver", 37 | "protractor", 38 | "selenium", 39 | "webdriverio", 40 | "codeceptjs" 41 | ], 42 | "license": "MIT", 43 | "devDependencies": { 44 | "@babel/core": "^7.1.2", 45 | "@babel/preset-env": "^7.1.0", 46 | "@wdio/selenium-standalone-service": "^6.4.0", 47 | "@webcomponents/webcomponentsjs": "^2.0.2", 48 | "babelrc-rollup": "^3.0.0", 49 | "eslint": "^7.19.0", 50 | "jasmine": "^3.1.0", 51 | "karma": "^6.2.0", 52 | "karma-chrome-launcher": "^2.2.0", 53 | "karma-coverage": "^2.0.3", 54 | "karma-firefox-launcher": "^1.1.0", 55 | "karma-jasmine": "^1.1.2", 56 | "karma-rollup-preprocessor": "^7.0.6", 57 | "karma-spec-reporter": "0.0.32", 58 | "npm-watch": "^0.7.0", 59 | "protractor": "^7.0.0", 60 | "puppeteer": "^5.2.0", 61 | "rollup": "^2.41.2", 62 | "rollup-plugin-babel": "^4.0.3", 63 | "rollup-plugin-istanbul": "^2.0.1", 64 | "rollup-plugin-sourcemaps": "^0.4.2", 65 | "rollup-plugin-terser": "^7.0.2", 66 | "selenium-standalone": "^6.19.0", 67 | "webdriverio": "^6.4.5" 68 | }, 69 | "repository": { 70 | "type": "git", 71 | "url": "https://github.com/webdriverio/query-selector-shadow-dom" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /plugins/codeceptjs/README.md: -------------------------------------------------------------------------------- 1 | # Support for Shadow DOM in CodeceptJS 2 | 3 | - Supported CodeceptJS Helpers: *Playwright* only. 4 | - CodeceptJS 2.6.0 released webdriver.io support as an alternative if you don't want to use Playwright 5 | 6 | Support for this plugin is currently limited to Playwright, this is mostly due to the fact that playwright 7 | allows for the addition of `custom selector engines`. 8 | 9 | Goal/Example: To be able to write a test that works easily with shadow dom web components. 10 | See Issues for what currently works and what doesn't 11 | 12 | ```javascript 13 | Feature("The chrome downloads page"); 14 | Scenario("Can interact with the search box", async I => { 15 | I.amOnPage("chrome://downloads"); 16 | I.see("Files you download appear here", {shadow: "#no-downloads span"}); 17 | I.waitForVisible({shadow: "#no-downloads"}, 5); 18 | I.click({shadow: `[title="Search downloads"]`}); 19 | I.waitForVisible({shadow: '#searchInput'}, 5); 20 | I.fillField({shadow: '#searchInput'}, "A download") 21 | I.waitForValue({shadow: '#searchInput'}, "A download", 5) 22 | I.waitForText("No search results found", 3, {shadow: "#no-downloads span"}); 23 | I.clearField({shadow: '#searchInput'}) 24 | I.waitForValue({shadow: '#searchInput'}, "", 5) 25 | }); 26 | 27 | ``` 28 | 29 | Setup: 30 | 31 | 1. `npm install query-selector-shadow-dom codeceptjs playwright` 32 | 2. Setup a codeceptjs project: https://codecept.io/quickstart/ 33 | 3. In `codecept.config.js` add this shadow dom plugin 34 | 35 | ```javascript 36 | plugins: { 37 | shadowDom: { 38 | enabled: true, 39 | locator: "shadow", 40 | require: "query-selector-shadow-dom/plugins/codeceptjs" 41 | } 42 | } 43 | ``` 44 | 4. Start using the custom locator `{shadow: "..."}` You may rename the locator in the config file from "shadow" to something else. 45 | 5. Read issues below as not everything currently works. 46 | 47 | Issues: 48 | 49 | ## What works 50 | - Most of the APIs listed here should work with shadow dom https://codecept.io/helpers/Playwright/#playwright 51 | 52 | ### The following methods are not supported as of right now: 53 | - waitForNoVisibleElements (looking for help should be do-able, feel free to PR to CodeceptJS) 54 | -------------------------------------------------------------------------------- /plugins/codeceptjs/index.js: -------------------------------------------------------------------------------- 1 | const { selectorEngine } = require("../playwright"); 2 | const supportedHelpers = [ 3 | 'Playwright' 4 | ] 5 | const playwright = require('playwright'); 6 | 7 | module.exports = function(config) { 8 | const container = codeceptjs.container; 9 | const event = codeceptjs.event; 10 | const helpers = container.helpers() 11 | let helperName 12 | for (helperName of supportedHelpers) { 13 | if (Object.keys(helpers).indexOf(helperName) > -1) { 14 | helper = helpers[helperName]; 15 | } 16 | } 17 | if (!helper) { 18 | throw new Error(`Shadow dom plugin only supports: ${supportedHelpers.join(',')}`) 19 | } 20 | if (!config) { 21 | config = {} 22 | } 23 | if (!config.locator) { 24 | config.locator = "shadow" 25 | } 26 | 27 | event.dispatcher.on(event.suite.before, async () => { 28 | if(helperName === "Playwright") { 29 | // temp handle api change in playwright may need to move to major version lib for documentation 30 | try { 31 | await playwright.selectors.register(selectorEngine, { name: config.locator }); 32 | } catch(e) { 33 | await playwright.selectors.register(config.locator, selectorEngine); 34 | } 35 | } 36 | }); 37 | } -------------------------------------------------------------------------------- /plugins/playwright/index.js: -------------------------------------------------------------------------------- 1 | 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | // load the library in UMD format which self executes and adds window.querySelectorShadowDom 6 | const querySelectorShadowDomUMD = fs.readFileSync(path.resolve(__dirname, "../../dist/querySelectorShadowDom.js")) 7 | 8 | // a string because playwright does a .toString on a selector engine and we need to 9 | // make sure that query-selector-shadow-dom is injected and loaded into the function closure 10 | const engineString =` 11 | ${querySelectorShadowDomUMD} 12 | return { 13 | create(root, target) { 14 | return undefined; 15 | }, 16 | query(root, selector) { 17 | return querySelectorShadowDom.querySelectorDeep(selector, root); 18 | }, 19 | queryAll(root, selector) { 20 | return querySelectorShadowDom.querySelectorAllDeep(selector, root); 21 | } 22 | } 23 | ` 24 | const selectorEngine = new Function("", engineString) 25 | 26 | module.exports = { selectorEngine }; -------------------------------------------------------------------------------- /plugins/protractor/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'protractor' { 2 | import { Locator } from 'protractor'; 3 | 4 | export interface ProtractorBy { 5 | /** 6 | * Find element within the Shadow DOM. 7 | * 8 | * @param {string} selector 9 | * @returns {Locator} location strategy 10 | */ 11 | shadowDomCss(selector: string): Locator; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /plugins/protractor/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const querySelectorAllDeep = fs.readFileSync(path.resolve(__dirname, "../../dist/querySelectorShadowDom.js")) 4 | 5 | module.exports = { 6 | name: 'query-selector-shadow-dom', 7 | onPrepare: function() { 8 | global.by.addLocator('shadowDomCss', ` 9 | var selector /* string */ = arguments[0]; 10 | var parentElement /* WebElement? */ = arguments[1]; 11 | var rootSelector /* string? */ = arguments[2]; 12 | 13 | ${ querySelectorAllDeep } 14 | 15 | return querySelectorShadowDom.querySelectorAllDeep(selector, parentElement || document) 16 | `); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /plugins/puppeteer/index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const querySelectorShadowDomUMD = fs.readFileSync(path.resolve(__dirname, "../../dist/querySelectorShadowDom.js")) 5 | 6 | const QueryHandler = { 7 | queryOne: new Function('element', 'selector', ` 8 | ${querySelectorShadowDomUMD} 9 | return querySelectorShadowDom.querySelectorDeep(selector, element); 10 | `), 11 | queryAll: new Function('element', 'selector', ` 12 | ${querySelectorShadowDomUMD} 13 | return querySelectorShadowDom.querySelectorAllDeep(selector, element); 14 | `) 15 | }; 16 | module.exports.QueryHandler = QueryHandler; -------------------------------------------------------------------------------- /plugins/webdriverio/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const querySelectorAllDeep = fs.readFileSync(path.resolve(__dirname, "../../dist/querySelectorShadowDom.js")) 4 | 5 | const selectorFunction = new Function('selector', 'element', ` 6 | ${querySelectorAllDeep} 7 | return querySelectorShadowDom.querySelectorAllDeep(selector, element); 8 | `); 9 | 10 | module.exports.locatorStrategy = selectorFunction; -------------------------------------------------------------------------------- /protractor.conf.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | baseUrl: 'http://localhost:3000', 3 | 4 | chromeDriver: process.env.CHROMEDRIVER_FILEPATH || require(`chromedriver/lib/chromedriver`).path, 5 | SELENIUM_PROMISE_MANAGER: false, 6 | directConnect: true, 7 | 8 | // https://github.com/angular/protractor/blob/main/docs/timeouts.md 9 | allScriptsTimeout: 110000, 10 | 11 | specs: [ './test/protractor-locator.e2e.js', ], 12 | 13 | plugins: [{ 14 | path: './plugins/protractor' 15 | }], 16 | 17 | onPrepare: function() { 18 | global.browser.waitForAngularEnabled(false); 19 | }, 20 | 21 | capabilities: { 22 | browserName: 'chrome', 23 | 24 | chromeOptions: { 25 | args: [ 26 | '--no-sandbox', 27 | '--disable-infobars', 28 | '--disable-dev-shm-usage', 29 | '--disable-extensions', 30 | '--log-level=3', 31 | '--disable-gpu', 32 | '--window-size=1920,1080', 33 | '--headless' 34 | ] 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /puppeteer-es5.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | (async() => { 5 | try { 6 | const browser = await puppeteer.launch() 7 | const page = await browser.newPage() 8 | await page.goto('https://www.polymer-project.org/2.0/docs/upgrade') 9 | await page.addScriptTag({ 10 | path: path.join(__dirname, 'node_modules/query-selector-shadow-dom/dist/querySelectorShadowDom.js') 11 | }); 12 | 13 | // execute standard javascript in the context of the page. 14 | const downloads = await page.evaluate(() => { 15 | const anchors = Array.from(querySelectorShadowDom.querySelectorAllDeep('a')) 16 | return anchors.map(anchor => anchor.href) 17 | }) 18 | console.log(downloads) 19 | await browser.close() 20 | } catch (e) { 21 | console.error(e); 22 | } 23 | 24 | })() -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import babelrc from 'babelrc-rollup'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | 5 | const babelConfig = { 6 | 'presets': [ 7 | ['@babel/preset-env', { 8 | 'targets': { 9 | 'browsers': ['last 2 versions', 'IE >= 11'] 10 | }, 11 | 'loose': true 12 | }] 13 | ] 14 | }; 15 | 16 | export default { 17 | input: 'src/querySelectorDeep.js', 18 | plugins: [ 19 | babel(babelrc({ 20 | addExternalHelpersPlugin: false, 21 | config: babelConfig, 22 | exclude: 'node_modules/**' 23 | })), 24 | terser() 25 | ], 26 | output: { 27 | format: 'iife', 28 | name: 'querySelectorShadowDom', 29 | file: 'dist/querySelectorShadowDom.js' 30 | } 31 | }; -------------------------------------------------------------------------------- /src/normalize.js: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | 4 | // normalize-selector-rev-02.js 5 | /* 6 | author: kyle simpson (@getify) 7 | original source: https://gist.github.com/getify/9679380 8 | 9 | modified for tests by david kaye (@dfkaye) 10 | 21 march 2014 11 | 12 | rev-02 incorporate kyle's changes 3/2/42014 13 | */ 14 | 15 | export function normalizeSelector(sel) { 16 | // save unmatched text, if any 17 | function saveUnmatched() { 18 | if (unmatched) { 19 | // whitespace needed after combinator? 20 | if (tokens.length > 0 && /^[~+>]$/.test(tokens[tokens.length - 1])) { 21 | tokens.push(" "); 22 | } 23 | 24 | // save unmatched text 25 | tokens.push(unmatched); 26 | } 27 | } 28 | 29 | var tokens = [], 30 | match, 31 | unmatched, 32 | regex, 33 | state = [0], 34 | next_match_idx = 0, 35 | prev_match_idx, 36 | not_escaped_pattern = /(?:[^\\]|(?:^|[^\\])(?:\\\\)+)$/, 37 | whitespace_pattern = /^\s+$/, 38 | state_patterns = [ 39 | /\s+|\/\*|["'>~+[(]/g, // general 40 | /\s+|\/\*|["'[\]()]/g, // [..] set 41 | /\s+|\/\*|["'[\]()]/g, // (..) set 42 | null, // string literal (placeholder) 43 | /\*\//g, // comment 44 | ]; 45 | sel = sel.trim(); 46 | 47 | // eslint-disable-next-line no-constant-condition 48 | while (true) { 49 | unmatched = ""; 50 | 51 | regex = state_patterns[state[state.length - 1]]; 52 | 53 | regex.lastIndex = next_match_idx; 54 | match = regex.exec(sel); 55 | 56 | // matched text to process? 57 | if (match) { 58 | prev_match_idx = next_match_idx; 59 | next_match_idx = regex.lastIndex; 60 | 61 | // collect the previous string chunk not matched before this token 62 | if (prev_match_idx < next_match_idx - match[0].length) { 63 | unmatched = sel.substring( 64 | prev_match_idx, 65 | next_match_idx - match[0].length 66 | ); 67 | } 68 | 69 | // general, [ ] pair, ( ) pair? 70 | if (state[state.length - 1] < 3) { 71 | saveUnmatched(); 72 | 73 | // starting a [ ] pair? 74 | if (match[0] === "[") { 75 | state.push(1); 76 | } 77 | // starting a ( ) pair? 78 | else if (match[0] === "(") { 79 | state.push(2); 80 | } 81 | // starting a string literal? 82 | else if (/^["']$/.test(match[0])) { 83 | state.push(3); 84 | state_patterns[3] = new RegExp(match[0], "g"); 85 | } 86 | // starting a comment? 87 | else if (match[0] === "/*") { 88 | state.push(4); 89 | } 90 | // ending a [ ] or ( ) pair? 91 | else if (/^[\])]$/.test(match[0]) && state.length > 0) { 92 | state.pop(); 93 | } 94 | // handling whitespace or a combinator? 95 | else if (/^(?:\s+|[~+>])$/.test(match[0])) { 96 | // need to insert whitespace before? 97 | if ( 98 | tokens.length > 0 && 99 | !whitespace_pattern.test(tokens[tokens.length - 1]) && 100 | state[state.length - 1] === 0 101 | ) { 102 | // add normalized whitespace 103 | tokens.push(" "); 104 | } 105 | 106 | // case-insensitive attribute selector CSS L4 107 | if ( 108 | state[state.length - 1] === 1 && 109 | tokens.length === 5 && 110 | tokens[2].charAt(tokens[2].length - 1) === "=" 111 | ) { 112 | tokens[4] = " " + tokens[4]; 113 | } 114 | 115 | // whitespace token we can skip? 116 | if (whitespace_pattern.test(match[0])) { 117 | continue; 118 | } 119 | } 120 | 121 | // save matched text 122 | tokens.push(match[0]); 123 | } 124 | // otherwise, string literal or comment 125 | else { 126 | // save unmatched text 127 | tokens[tokens.length - 1] += unmatched; 128 | 129 | // unescaped terminator to string literal or comment? 130 | if (not_escaped_pattern.test(tokens[tokens.length - 1])) { 131 | // comment terminator? 132 | if (state[state.length - 1] === 4) { 133 | // ok to drop comment? 134 | if ( 135 | tokens.length < 2 || 136 | whitespace_pattern.test(tokens[tokens.length - 2]) 137 | ) { 138 | tokens.pop(); 139 | } 140 | // otherwise, turn comment into whitespace 141 | else { 142 | tokens[tokens.length - 1] = " "; 143 | } 144 | 145 | // handled already 146 | match[0] = ""; 147 | } 148 | 149 | state.pop(); 150 | } 151 | 152 | // append matched text to existing token 153 | tokens[tokens.length - 1] += match[0]; 154 | } 155 | } 156 | // otherwise, end of processing (no more matches) 157 | else { 158 | unmatched = sel.substr(next_match_idx); 159 | saveUnmatched(); 160 | 161 | break; 162 | } 163 | } 164 | 165 | return tokens.join("").trim(); 166 | } 167 | -------------------------------------------------------------------------------- /src/querySelectorDeep.js: -------------------------------------------------------------------------------- 1 | import { normalizeSelector } from './normalize'; 2 | 3 | /** 4 | * Finds first matching elements on the page that may be in a shadow root using a complex selector of n-depth 5 | * 6 | * Don't have to specify all shadow roots to button, tree is travered to find the correct element 7 | * 8 | * Example querySelectorAllDeep('downloads-item:nth-child(4) #remove'); 9 | * 10 | * Example should work on chrome://downloads outputting the remove button inside of a download card component 11 | * 12 | * Example find first active download link element querySelectorDeep('#downloads-list .is-active a[href^="https://"]'); 13 | * 14 | * Another example querySelectorAllDeep('#downloads-list div#title-area + a'); 15 | e.g. 16 | */ 17 | export function querySelectorAllDeep(selector, root = document, allElements = null) { 18 | return _querySelectorDeep(selector, true, root, allElements); 19 | } 20 | 21 | export function querySelectorDeep(selector, root = document, allElements = null) { 22 | return _querySelectorDeep(selector, false, root, allElements); 23 | } 24 | 25 | function _querySelectorDeep(selector, findMany, root, allElements = null) { 26 | selector = normalizeSelector(selector); 27 | let lightElement = root.querySelector(selector); 28 | 29 | if (document.head.createShadowRoot || document.head.attachShadow) { 30 | // no need to do any special if selector matches something specific in light-dom 31 | if (!findMany && lightElement) { 32 | return lightElement; 33 | } 34 | 35 | // split on commas because those are a logical divide in the operation 36 | const selectionsToMake = splitByCharacterUnlessQuoted(selector, ','); 37 | 38 | return selectionsToMake.reduce((acc, minimalSelector) => { 39 | // if not finding many just reduce the first match 40 | if (!findMany && acc) { 41 | return acc; 42 | } 43 | // do best to support complex selectors and split the query 44 | const splitSelector = splitByCharacterUnlessQuoted(minimalSelector 45 | //remove white space at start of selector 46 | .replace(/^\s+/g, '') 47 | .replace(/\s*([>+~]+)\s*/g, '$1'), ' ') 48 | // filter out entry white selectors 49 | .filter((entry) => !!entry) 50 | // convert "a > b" to ["a", "b"] 51 | .map((entry) => splitByCharacterUnlessQuoted(entry, '>')); 52 | 53 | const possibleElementsIndex = splitSelector.length - 1; 54 | const lastSplitPart = splitSelector[possibleElementsIndex][splitSelector[possibleElementsIndex].length - 1]; 55 | const possibleElements = collectAllElementsDeep(lastSplitPart, root, allElements); 56 | const findElements = findMatchingElement(splitSelector, possibleElementsIndex, root); 57 | if (findMany) { 58 | acc = acc.concat(possibleElements.filter(findElements)); 59 | return acc; 60 | } else { 61 | acc = possibleElements.find(findElements); 62 | return acc || null; 63 | } 64 | }, findMany ? [] : null); 65 | 66 | 67 | } else { 68 | if (!findMany) { 69 | return lightElement; 70 | } else { 71 | return root.querySelectorAll(selector); 72 | } 73 | } 74 | 75 | } 76 | 77 | function findMatchingElement(splitSelector, possibleElementsIndex, root) { 78 | return (element) => { 79 | let position = possibleElementsIndex; 80 | let parent = element; 81 | let foundElement = false; 82 | while (parent && !isDocumentNode(parent)) { 83 | let foundMatch = true; 84 | if (splitSelector[position].length === 1) { 85 | foundMatch = parent.matches(splitSelector[position]); 86 | } else { 87 | // selector is in the format "a > b" 88 | // make sure a few parents match in order 89 | const reversedParts = ([]).concat(splitSelector[position]).reverse(); 90 | let newParent = parent; 91 | for (const part of reversedParts) { 92 | if (!newParent || !newParent.matches(part)) { 93 | foundMatch = false; 94 | break; 95 | } 96 | newParent = findParentOrHost(newParent, root); 97 | } 98 | } 99 | 100 | if (foundMatch && position === 0) { 101 | foundElement = true; 102 | break; 103 | } 104 | if (foundMatch) { 105 | position--; 106 | } 107 | parent = findParentOrHost(parent, root); 108 | } 109 | return foundElement; 110 | }; 111 | 112 | } 113 | 114 | function splitByCharacterUnlessQuoted(selector, character) { 115 | return selector.match(/\\?.|^$/g).reduce((p, c) => { 116 | if (c === '"' && !p.sQuote) { 117 | p.quote ^= 1; 118 | p.a[p.a.length - 1] += c; 119 | } else if (c === '\'' && !p.quote) { 120 | p.sQuote ^= 1; 121 | p.a[p.a.length - 1] += c; 122 | 123 | } else if (!p.quote && !p.sQuote && c === character) { 124 | p.a.push(''); 125 | } else { 126 | p.a[p.a.length - 1] += c; 127 | } 128 | return p; 129 | }, { a: [''] }).a; 130 | } 131 | 132 | /** 133 | * Checks if the node is a document node or not. 134 | * @param {Node} node 135 | * @returns {node is Document | DocumentFragment} 136 | */ 137 | function isDocumentNode(node) { 138 | return node.nodeType === Node.DOCUMENT_FRAGMENT_NODE || node.nodeType === Node.DOCUMENT_NODE; 139 | } 140 | 141 | function findParentOrHost(element, root) { 142 | const parentNode = element.parentNode; 143 | return (parentNode && parentNode.host && parentNode.nodeType === 11) ? parentNode.host : parentNode === root ? null : parentNode; 144 | } 145 | 146 | /** 147 | * Finds all elements on the page, inclusive of those within shadow roots. 148 | * @param {string=} selector Simple selector to filter the elements by. e.g. 'a', 'div.main' 149 | * @return {!Array} List of anchor hrefs. 150 | * @author ebidel@ (Eric Bidelman) 151 | * License Apache-2.0 152 | */ 153 | export function collectAllElementsDeep(selector = null, root, cachedElements = null) { 154 | let allElements = []; 155 | 156 | if (cachedElements) { 157 | allElements = cachedElements; 158 | } else { 159 | const findAllElements = function(nodes) { 160 | for (let i = 0; i < nodes.length; i++) { 161 | const el = nodes[i]; 162 | allElements.push(el); 163 | // If the element has a shadow root, dig deeper. 164 | if (el.shadowRoot) { 165 | findAllElements(el.shadowRoot.querySelectorAll('*')); 166 | } 167 | } 168 | }; 169 | if(root.shadowRoot) { 170 | findAllElements(root.shadowRoot.querySelectorAll('*')); 171 | } 172 | findAllElements(root.querySelectorAll('*')); 173 | } 174 | 175 | return selector ? allElements.filter(el => el.matches(selector)) : allElements; } 176 | 177 | -------------------------------------------------------------------------------- /steps.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type steps_file = typeof import('./steps_file.js'); 3 | 4 | declare namespace CodeceptJS { 5 | interface SupportObject { I: CodeceptJS.I } 6 | interface CallbackOrder { [0]: CodeceptJS.I } 7 | interface Methods extends CodeceptJS.Playwright {} 8 | interface I extends ReturnType {} 9 | namespace Translation { 10 | interface Actions {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /steps_file.js: -------------------------------------------------------------------------------- 1 | // in this file you can append custom step methods to 'I' object 2 | 3 | module.exports = function() { 4 | return actor({ 5 | 6 | // Define custom steps here, use 'this' to access default methods of I. 7 | // It is recommended to place a general 'login' function here. 8 | 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /test/TestComponent.js: -------------------------------------------------------------------------------- 1 | export class TestComponent extends HTMLElement { 2 | 3 | constructor({ childClassName = 'test-child', childTextContent = 'Child Content', internalHTML = '' } = {}) { 4 | super(); 5 | this.childClassName = childClassName; 6 | this.childTextContent = childTextContent; 7 | this.internalHTML = internalHTML; 8 | this.style.display = 'block'; 9 | this.style.margin = '5px'; 10 | } 11 | 12 | connectedCallback() { 13 | this.attachShadow({ mode: 'open' }); 14 | this.shadowRoot.innerHTML = `

${this.childTextContent}

${this.internalHTML}
`; 15 | } 16 | 17 | add(child) { 18 | this.shadowRoot.appendChild(child); 19 | } 20 | 21 | addNested(child) { 22 | this.shadowRoot.querySelector('p').appendChild(child); 23 | } 24 | 25 | static get observedAttributes() { 26 | return ['child-class-name', 'child-text-content', 'internal-html']; 27 | } 28 | 29 | attributeChangedCallback(name, oldValue, newValue) { 30 | switch (name) { 31 | case 'child-class-name': 32 | this.childClassName = newValue; 33 | break; 34 | case 'child-text-content': 35 | this.childTextContent = newValue; 36 | break; 37 | case 'internal-html': 38 | this.internalHTML = newValue; 39 | break; 40 | } 41 | } 42 | } 43 | customElements.define('test-component', TestComponent); -------------------------------------------------------------------------------- /test/basic.spec.js: -------------------------------------------------------------------------------- 1 | import { querySelectorAllDeep, querySelectorDeep, collectAllElementsDeep } from '../src/querySelectorDeep.js'; 2 | import { createTestComponent, createNestedComponent, COMPONENT_NAME, createChildElements } from './createTestComponent.js'; 3 | 4 | 5 | 6 | describe("Basic Suite", function() { 7 | 8 | let parent; 9 | let baseComponent; 10 | 11 | function setup() { 12 | parent = document.createElement('div'); 13 | document.body.appendChild(parent); 14 | baseComponent = createTestComponent(parent, { 15 | childClassName: 'base', 16 | childTextContent: 'Base Component' 17 | }); 18 | baseComponent.classList.add('base-comp'); 19 | } 20 | beforeEach(() => { 21 | setup(); 22 | }); 23 | 24 | afterEach(() => { 25 | parent.remove(); 26 | }); 27 | 28 | it("exports querySelectorAllDeep function", function() { 29 | expect(querySelectorAllDeep).toEqual(jasmine.any(Function)); 30 | }); 31 | 32 | it("exports querySelectorDeep function", function() { 33 | expect(querySelectorDeep).toEqual(jasmine.any(Function)); 34 | }); 35 | 36 | it("exports collectAllElementsDeep function", function() { 37 | expect(collectAllElementsDeep).toEqual(jasmine.any(Function)); 38 | }); 39 | 40 | it("querySelectorDeep returns null when not found", function() { 41 | expect(querySelectorDeep('whatever')).toBeNull(); 42 | }); 43 | 44 | it("querySelectorAllDeep returns empty array when not found", function() { 45 | const foundElements = querySelectorAllDeep('whatever'); 46 | expect(foundElements).toEqual(jasmine.any(Array)); 47 | expect(foundElements.length).toEqual(0); 48 | }); 49 | 50 | 51 | describe("querySelectorDeep", function() { 52 | it('can access an element in the light dom', function() { 53 | createTestComponent(parent); 54 | const testComponent = querySelectorDeep(COMPONENT_NAME); 55 | expect(testComponent).toBeTruthy(); 56 | }); 57 | 58 | it('can access direct shadow dom child of the root', function() { 59 | const rootComponent = createTestComponent(parent, { 60 | childClassName: "child-class", 61 | }); 62 | const child = querySelectorDeep('.child-class', rootComponent); 63 | expect(child).toBeTruthy(); 64 | }); 65 | 66 | 67 | it('can access an element in the shadow dom', function() { 68 | createTestComponent(parent, { 69 | childTextContent: 'Child Content' 70 | }); 71 | const testSubComponent = querySelectorDeep('.test-child'); 72 | expect(testSubComponent).toBeTruthy(); 73 | expect(testSubComponent.textContent).toEqual('Child Content') 74 | }); 75 | 76 | it('can access an element nested in many shadow dom', function() { 77 | createNestedComponent(baseComponent, 20); 78 | const testSubComponent = querySelectorDeep('.desc-20'); 79 | expect(testSubComponent).toBeTruthy(); 80 | expect(testSubComponent.textContent).toEqual('Descendant 20') 81 | }); 82 | 83 | 84 | it('can use find the nth-child inside of a shadow root', function() { 85 | createNestedComponent(baseComponent, 10, { 86 | createChildren: createChildElements 87 | }); 88 | const testSubComponent = querySelectorDeep('.desc-5 div:nth-child(2)'); 89 | expect(testSubComponent).toBeTruthy(); 90 | expect(testSubComponent.textContent).toEqual('Child 2') 91 | }); 92 | 93 | it('selector list only returns the first element', function() { 94 | createNestedComponent(baseComponent, 10, { 95 | createChildren: createChildElements 96 | }); 97 | const testSubComponent = querySelectorDeep('.desc-5 div:nth-child(2), .desc-1'); 98 | expect(testSubComponent).toBeTruthy(); 99 | expect(testSubComponent.textContent).toEqual('Child 2') 100 | }); 101 | 102 | it('returns null when reaching the document node and no matching parent was found', function() { 103 | const rootComponent = createTestComponent(parent, { 104 | childClassName: 'child', 105 | }) 106 | const testSubComponent = querySelectorDeep('.parent .child', rootComponent); 107 | expect(testSubComponent).toBeNull(); 108 | }); 109 | }); 110 | 111 | 112 | describe("querySelectorAll", function() { 113 | 114 | it('can locate all instances of components across even in shadow dom (except base)', function() { 115 | createNestedComponent(baseComponent, 10); 116 | const testComponents = querySelectorAllDeep(`test-component:not(.base-comp)`); 117 | expect(testComponents.length).toEqual(10); 118 | 119 | }); 120 | 121 | it('can get elements matching or selector', function() { 122 | createTestComponent(parent, { 123 | childClassName: 'header-1' 124 | }); 125 | const element = createTestComponent(parent, { 126 | childClassName: 'header-2' 127 | }); 128 | element.classList.add('parent') 129 | const testComponents = querySelectorAllDeep(`.header-1, .header-2, .parent`); 130 | expect(testComponents.length).toEqual(3); 131 | 132 | }); 133 | 134 | it('host property on non shadowRoot element is ignored', function() { 135 | const testComponent = createTestComponent(parent, { 136 | childClassName: 'header-1', 137 | internalHTML: '
' 138 | }); 139 | testComponent.shadowRoot.querySelector('.header-2').host = "test.com"; 140 | testComponent.classList.add('container'); 141 | const testComponents = querySelectorAllDeep(`.container .find-me`); 142 | expect(testComponents.length).toEqual(1); 143 | }); 144 | 145 | it('can see inside the shadowRoot with ">" in selector', function() { 146 | const testComponent = createTestComponent(parent, { 147 | childClassName: 'header-1', 148 | internalHTML: '
' 149 | }); 150 | testComponent.shadowRoot.querySelector('.header-2').host = "test.com"; 151 | testComponent.classList.add('container'); 152 | const testComponents = querySelectorAllDeep(`.container > div > .header-2 > .find-me`); 153 | expect(testComponents.length).toEqual(1); 154 | expect(testComponents[0].classList.contains('find-me')).toEqual(true); 155 | }); 156 | 157 | it('handles descendant selector > that dooes not match child', function() { 158 | const testComponent = createTestComponent(parent, { 159 | childClassName: 'header-1', 160 | internalHTML: '
' 161 | }); 162 | testComponent.shadowRoot.querySelector('.header-2').host = "test.com"; 163 | testComponent.classList.add('container'); 164 | const testComponents = querySelectorAllDeep(`.container > div > .header-2 > .doesnt-exist`); 165 | expect(testComponents.length).toEqual(0); 166 | }); 167 | 168 | it('handles descendant selector where child exists but parent does not', function() { 169 | const testComponent = createTestComponent(parent, { 170 | childClassName: 'header-1', 171 | internalHTML: '
' 172 | }); 173 | testComponent.shadowRoot.querySelector('.header-2').host = "test.com"; 174 | testComponent.classList.add('container'); 175 | const testComponents = querySelectorAllDeep(`.container > div > .doesnt-exist > .find-me`); 176 | expect(testComponents.length).toEqual(0); 177 | }); 178 | 179 | 180 | it('can handle extra white space in selectors', function() { 181 | const testComponent = createTestComponent(parent, { 182 | childClassName: 'header-1', 183 | internalHTML: '
Content
' 184 | }); 185 | createTestComponent(testComponent, { 186 | childClassName: 'header-2' 187 | }); 188 | testComponent.classList.add('header-1'); 189 | const testComponents = querySelectorAllDeep(`.header-1 .header-2`); 190 | expect(testComponents.length).toEqual(2); 191 | 192 | }); 193 | 194 | it('can handle attribute selector value', function() { 195 | const testComponent = createTestComponent(parent, { 196 | childClassName: 'header-1', 197 | internalHTML: '
Content
' 198 | }); 199 | createTestComponent(testComponent, { 200 | childClassName: 'header-2' 201 | }); 202 | testComponent.setAttribute('data-test', '123') 203 | testComponent.classList.add('header-1'); 204 | const testComponents = querySelectorAllDeep(`.header-1 [data-test="Hello-World"]`); 205 | expect(testComponents.length).toEqual(1); 206 | expect(testComponents[0].classList.contains('header-2')).toBeTruthy(); 207 | }); 208 | 209 | 210 | it('can handle extra white space in attribute value', function() { 211 | const testComponent = createTestComponent(parent, { 212 | childClassName: 'header-1', 213 | internalHTML: '
Content
' 214 | }); 215 | createTestComponent(testComponent, { 216 | childClassName: 'header-2' 217 | }); 218 | // this should not match as matching children 219 | testComponent.setAttribute('data-test', 'Hello World') 220 | testComponent.classList.add('header-1'); 221 | const testComponents = querySelectorAllDeep(`.header-1 [data-test="Hello World"]`); 222 | expect(testComponents.length).toEqual(1); 223 | }); 224 | 225 | 226 | it('can handle comma in attribute values', function() { 227 | const testComponent = createTestComponent(parent, { 228 | childClassName: 'header-1', 229 | internalHTML: '
Content
' 230 | }); 231 | const test2 = createTestComponent(testComponent, { 232 | childClassName: 'header-2' 233 | }); 234 | test2.setAttribute('data-test', 'Hello, World') 235 | testComponent.classList.add('header-1'); 236 | const testComponents = querySelectorAllDeep(`.header-1 [data-test="Hello, World"], .header-2, .header-1`); 237 | expect(testComponents.length).toEqual(5); 238 | }); 239 | 240 | it('can handle spacing around attribute values', function() { 241 | const testComponent = createTestComponent(parent, { 242 | childClassName: 'header-1', 243 | internalHTML: '
Content
' 244 | }); 245 | const test2 = createTestComponent(testComponent, { 246 | childClassName: 'header-2' 247 | }); 248 | test2.setAttribute('data-test', 'Hello, World') 249 | testComponent.classList.add('header-1'); 250 | const testComponents = querySelectorAllDeep(`.header-1 [ data-test = "Hello, World" ], .header-2, .header-1`); 251 | expect(testComponents.length).toEqual(5); 252 | }); 253 | 254 | it('can handle spacing around attribute values with [ in attribute', function() { 255 | const testComponent = createTestComponent(parent, { 256 | childClassName: 'header-1', 257 | internalHTML: '
Content
' 258 | }); 259 | const test2 = createTestComponent(testComponent, { 260 | childClassName: 'header-2' 261 | }); 262 | test2.setAttribute('data-braces-test', ' [ Hello, World ] ') 263 | testComponent.classList.add('header-1'); 264 | const testComponents = querySelectorAllDeep(`.header-1 [ data-braces-test = " [ Hello, World ] " ], .header-2, .header-1`); 265 | expect(testComponents.length).toEqual(5); 266 | }); 267 | 268 | it('can escaped comma in attribute values', function() { 269 | const testComponent = createTestComponent(parent, { 270 | childClassName: 'header-1', 271 | internalHTML: '
Content
' 272 | }); 273 | const test2 = createTestComponent(testComponent, { 274 | childClassName: 'header-2' 275 | }); 276 | test2.setAttribute('data-test', 'Hello\, World') 277 | testComponent.classList.add('header-1'); 278 | const testComponents = querySelectorAllDeep(`.header-1 [data-test="Hello\, World"]`); 279 | expect(testComponents.length).toEqual(1); 280 | }); 281 | 282 | 283 | it('can handle escaped data in attributes', function() { 284 | const testComponent = createTestComponent(parent, { 285 | childClassName: 'header-1', 286 | internalHTML: '
Content
' 287 | }); 288 | const test2 = createTestComponent(testComponent, { 289 | childClassName: 'header-2' 290 | }); 291 | test2.setAttribute('data-test', 'Hello" World') 292 | testComponent.classList.add('header-1'); 293 | const testComponents = querySelectorAllDeep(`.header-1 [data-test="Hello\\" World"]`); 294 | expect(testComponents.length).toEqual(1); 295 | }); 296 | 297 | it('can handle extra white space in single quoted attribute value', function() { 298 | const testComponent = createTestComponent(parent, { 299 | childClassName: 'header-1', 300 | internalHTML: '
Content
' 301 | }); 302 | createTestComponent(testComponent, { 303 | childClassName: 'header-2' 304 | }); 305 | testComponent.setAttribute('data-test', 'Hello " \'World\'') 306 | testComponent.classList.add('header-1'); 307 | const testComponents = querySelectorAllDeep(`.header-1[data-test='Hello \\" \\'World\\'']`); 308 | expect(testComponents.length).toEqual(1); 309 | }); 310 | 311 | it('split correctly on selector list', function() { 312 | const testComponent = createTestComponent(parent, { 313 | internalHTML: '
Content
' 314 | }); 315 | createTestComponent(testComponent, { 316 | childClassName: 'header-4' 317 | }); 318 | testComponent.setAttribute('data-test', '123') 319 | testComponent.classList.add('header-1'); 320 | const testComponents = querySelectorAllDeep(`.header-1,.header-2 + .header-3`); 321 | expect(testComponents.length).toEqual(2); 322 | expect(testComponents[1].classList.contains('header-3')).toBeTruthy(); 323 | }); 324 | 325 | it('split correctly on selector list (ignore white space)', function() { 326 | const testComponent = createTestComponent(parent, { 327 | internalHTML: '
Content
' 328 | }); 329 | createTestComponent(testComponent, { 330 | childClassName: 'header-4' 331 | }); 332 | testComponent.setAttribute('data-test', '123') 333 | testComponent.classList.add('header-1'); 334 | const testComponents = querySelectorAllDeep(` .header-1, .header-2 + .header-3`); 335 | expect(testComponents.length).toEqual(2); 336 | expect(testComponents[1].classList.contains('header-3')).toBeTruthy(); 337 | }); 338 | 339 | it('can provide an alternative node', function() { 340 | const root = document.createElement('div'); 341 | parent.appendChild(root); 342 | 343 | createTestComponent(root, { 344 | childClassName: 'inner-content' 345 | }); 346 | 347 | createTestComponent(parent, { 348 | childClassName: 'inner-content' 349 | }); 350 | const testComponent = querySelectorAllDeep('.inner-content', root); 351 | expect(testComponent.length).toEqual(1); 352 | 353 | }); 354 | 355 | it('can cache collected elements with collectAllElementsDeep', function() { 356 | const root = document.createElement('div'); 357 | parent.appendChild(root); 358 | 359 | createTestComponent(root, { 360 | childClassName: 'inner-content' 361 | }); 362 | 363 | createTestComponent(parent, { 364 | childClassName: 'inner-content' 365 | }); 366 | const collectedElements = collectAllElementsDeep('*', root) 367 | expect(collectedElements.length).toEqual(4); 368 | 369 | const testComponents = querySelectorAllDeep('.inner-content', root, collectedElements); 370 | expect(testComponents.length).toEqual(1); 371 | 372 | // remove element from dom 373 | testComponents[0].remove() 374 | 375 | // not found in dom 376 | const testComponents2 = querySelectorAllDeep('.inner-content', root); 377 | expect(testComponents2.length).toEqual(0); 378 | 379 | // still there with cached collectedElements 380 | const testComponents3 = querySelectorAllDeep('.inner-content', root, collectedElements); 381 | expect(testComponents3.length).toEqual(1); 382 | }); 383 | 384 | it('empty collectAllElementsDeep find all elements', function() { 385 | const root = document.createElement('div'); 386 | parent.appendChild(root); 387 | 388 | createTestComponent(root, { 389 | childClassName: 'inner-content' 390 | }); 391 | 392 | createTestComponent(parent, { 393 | childClassName: 'inner-content' 394 | }); 395 | const collectedElements = collectAllElementsDeep('', root); 396 | expect(collectedElements.length).toEqual(4); 397 | }); 398 | 399 | it('can query nodes in an iframe', function(done) { 400 | 401 | const innerframe = `

Content

`; 402 | createTestComponent(parent, { 403 | internalHTML: `` 404 | }); 405 | setTimeout(() => { 406 | const iframe = querySelectorDeep('#frame'); 407 | const testComponents = querySelectorAllDeep('.child', iframe.contentDocument); 408 | expect(testComponents.length).toEqual(1); 409 | expect(testComponents[0].textContent).toEqual("Content"); 410 | done(); 411 | }, 150); 412 | 413 | 414 | }); 415 | 416 | 417 | 418 | // describe(".perf", function() { 419 | 420 | // function generateQuerySelectorAllTest(count) { 421 | // it(`can create ${count} shadow roots and search for all instances that match`, async function() { 422 | // for (let i = 0; i < count; i++) { 423 | // createTestComponent(baseComponent, count); 424 | // } 425 | // const testComponents = querySelectorAllDeep('test-component [class=test-child]'); 426 | // expect(testComponents.length).toEqual(count); 427 | // }); 428 | // } 429 | 430 | // generateQuerySelectorAllTest(200000); 431 | 432 | // }); 433 | 434 | }); 435 | 436 | 437 | }); 438 | -------------------------------------------------------------------------------- /test/codeceptjs/README.md: -------------------------------------------------------------------------------- 1 | CodeceptJS functions to test: 2 | 3 | appendField 4 | attachFile 5 | checkOption 6 | clearField 7 | click 8 | clickLin 9 | dontSee 10 | dontSeeCheckboxIsChecked 11 | dontSeeCookie 12 | dontSeeCurrentUrlEquals 13 | dontSeeElement 14 | dontSeeElementInDOM 15 | dontSeeInField 16 | doubleClick 17 | dragAndDrop 18 | dragSlider 19 | fillField 20 | forceClick 21 | grabAttributeFrom 22 | grabCssPropertyFrom 23 | grabDataFromPerformanceTiming 24 | grabElementBoundingRect 25 | grabHTMLFrom 26 | grabNumberOfVisibleElements 27 | grabPageScrollPosition 28 | grabTextFrom 29 | grabValueFrom 30 | moveCursorTo 31 | openNewTab 32 | pressKey 33 | pressKeyDown 34 | pressKeyUp 35 | rightClick 36 | scrollPageToBottom 37 | scrollPageToTop 38 | scrollTo 39 | see 40 | seeAttributesOnElements 41 | seeCheckboxIsChecked 42 | seeCssPropertiesOnElements 43 | seeElement 44 | seeElementInDO 45 | seeInField 46 | seeNumberOfElements 47 | seeNumberOfVisibleElements 48 | seeTextEquals 49 | selectOptio 50 | uncheckOptio 51 | waitForClickable 52 | waitForDetached 53 | waitForElement 54 | waitForEnabled 55 | waitForInvisible 56 | waitForText 57 | waitForValue 58 | waitForVisible 59 | waitNumberOfVisibleElements 60 | waitToHide 61 | waitUntil -------------------------------------------------------------------------------- /test/codeceptjs/codecept.conf.js: -------------------------------------------------------------------------------- 1 | exports.config = { 2 | tests: 'test/codeceptjs/*.test.js', 3 | output: './output', 4 | helpers: { 5 | Playwright: { 6 | url: 'http://localhost', 7 | show: true, 8 | browser: 'chromium' 9 | } 10 | }, 11 | include: { 12 | I: './steps_file.js' 13 | }, 14 | bootstrap: null, 15 | mocha: {}, 16 | name: 'codeceptjs', 17 | plugins: { 18 | retryFailedStep: { 19 | enabled: true 20 | }, 21 | screenshotOnFail: { 22 | enabled: true 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /test/codeceptjs/components.test.js: -------------------------------------------------------------------------------- 1 | Feature('components'); 2 | 3 | Scenario('test something', (I) => { 4 | 5 | }); 6 | -------------------------------------------------------------------------------- /test/codeceptjs/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true 4 | } 5 | } -------------------------------------------------------------------------------- /test/codeceptjs/steps.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type steps_file = typeof import('./steps_file.js'); 3 | 4 | declare namespace CodeceptJS { 5 | interface SupportObject { I: CodeceptJS.I } 6 | interface CallbackOrder { [0]: CodeceptJS.I } 7 | interface Methods extends CodeceptJS.Playwright {} 8 | interface I extends ReturnType {} 9 | namespace Translation { 10 | interface Actions {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/codeceptjs/steps_file.js: -------------------------------------------------------------------------------- 1 | // in this file you can append custom step methods to 'I' object 2 | 3 | module.exports = function() { 4 | return actor({ 5 | 6 | // Define custom steps here, use 'this' to access default methods of I. 7 | // It is recommended to place a general 'login' function here. 8 | 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /test/createTestComponent.js: -------------------------------------------------------------------------------- 1 | import { TestComponent } from './TestComponent.js'; 2 | export function createTestComponent(parent, options = {}) { 3 | return parent.appendChild(new TestComponent(options)); 4 | } 5 | 6 | export const createChildElements = (component) => { 7 | for (let i = 0; i < 3; i++) { 8 | const child = document.createElement('div'); 9 | child.textContent = `Child ${i+1}`; 10 | component.addNested(child); 11 | } 12 | }; 13 | 14 | export function createNestedComponent(parent, count = 1, { createChildren = () => {}, childClass = (count) => `desc-${count}`, childContent = (count) => `Descendant ${count}` } = {}) { 15 | if (count > 2000) { 16 | const split = count / 2; 17 | for (let i = 0; i < count; i += split) { 18 | createNestedComponent(parent, split, { 19 | createChildren, 20 | childClass, 21 | childContent 22 | }); 23 | } 24 | } else { 25 | if (count === 0) { 26 | return; 27 | } 28 | const component = new TestComponent({ 29 | childClassName: childClass(count), 30 | childTextContent: childContent(count) 31 | }); 32 | parent.add(component); 33 | createChildren(component); 34 | count = count - 1; 35 | createNestedComponent(component, count, { createChildren, childClass, childContent }); 36 | } 37 | 38 | 39 | 40 | } 41 | 42 | export const COMPONENT_NAME = 'test-component'; -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Testing 9 | 10 | 11 | 12 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /test/nopolyfills.spec.js: -------------------------------------------------------------------------------- 1 | import { querySelectorAllDeep, querySelectorDeep } from '../src/querySelectorDeep.js'; 2 | 3 | describe("No Polyfills Suite", function() { 4 | 5 | let parent; 6 | 7 | function setup() { 8 | parent = document.createElement('div'); 9 | document.body.appendChild(parent); 10 | } 11 | beforeEach(() => { 12 | spyOnProperty(document, 'head', 'get').and.returnValue({ 13 | attachShadow: undefined, 14 | createShadowRoot: undefined 15 | }); 16 | setup(); 17 | }); 18 | 19 | afterEach(() => { 20 | parent.remove(); 21 | }); 22 | 23 | 24 | it('can fallback to query selector when no support and no polyfills', function() { 25 | const element = document.createElement('a'); 26 | element.classList.add('testing'); 27 | parent.appendChild(element); 28 | expect(querySelectorDeep('.testing')).toBeTruthy(); 29 | }); 30 | 31 | it('can fallback to query selector all when no support and no polyfills', function() { 32 | const element = document.createElement('a'); 33 | const element2 = document.createElement('a'); 34 | element.classList.add('testing'); 35 | element2.classList.add('testing'); 36 | parent.appendChild(element); 37 | parent.appendChild(element2); 38 | expect(querySelectorAllDeep('.testing').length).toEqual(2); 39 | }); 40 | 41 | it('can fallback to query selector when no support and no polyfills with alternative root', function() { 42 | const root = document.createElement('div'); 43 | parent.appendChild(root); 44 | const element = document.createElement('a'); 45 | const element2 = document.createElement('a'); 46 | element.classList.add('testing'); 47 | element2.classList.add('testing'); 48 | root.appendChild(element); 49 | parent.appendChild(element2); 50 | 51 | expect(querySelectorAllDeep('.testing', root).length).toEqual(1); 52 | }); 53 | 54 | 55 | 56 | }); -------------------------------------------------------------------------------- /test/protractor-locator.e2e.js: -------------------------------------------------------------------------------- 1 | describe('query-selector-shadow-dom', () => { 2 | 3 | beforeEach(async () => { 4 | await browser.get(pageFromTemplate(` 5 | var firstParent = document.createElement('div'); 6 | firstParent.id = 'first'; 7 | firstParent.classList.add('parent'); 8 | document.body.appendChild(firstParent); 9 | 10 | var secondParent = document.createElement('div'); 11 | secondParent.classList.add('parent'); 12 | document.body.appendChild(secondParent); 13 | 14 | var firstShadowChild = firstParent.attachShadow({ mode: 'open' }); 15 | firstShadowChild.innerHTML = '

First child with Shadow DOM

'; 16 | 17 | var secondShadowChild = secondParent.attachShadow({ mode: 'open' }); 18 | secondShadowChild.innerHTML = '

Second child with Shadow DOM

'; 19 | `)); 20 | }); 21 | 22 | it(`identifies a single element matching the selector`, async () => { 23 | const text = await element(by.shadowDomCss('#first.parent .shadow-child .name')).getText(); 24 | 25 | expect(text).toEqual('First child'); 26 | }); 27 | 28 | it(`identifies all elements matching the selector`, async () => { 29 | const text = await element.all(by.shadowDomCss('.parent .shadow-child .name')).getText(); 30 | 31 | expect(text).toEqual(['First child', 'Second child']); 32 | }); 33 | }); 34 | 35 | /** 36 | * Turns a HTML template into a data URL Protractor can navigate to without having to use a web server 37 | * 38 | * @param {string} template 39 | * @returns {string} 40 | */ 41 | function pageFromTemplate(template /* string */) /* string */ { 42 | return `data:text/html;charset=utf-8, 43 | 44 | 45 | 46 | 47 | `.replace(/[\s\n]+/s, ' '); 48 | } 49 | --------------------------------------------------------------------------------