diff --git a/src/__node_tests__/index.js b/src/__node_tests__/index.js
index 6c663360..6714639c 100644
--- a/src/__node_tests__/index.js
+++ b/src/__node_tests__/index.js
@@ -1,6 +1,14 @@
import {JSDOM} from 'jsdom'
+import {configure} from '../config'
import * as dtl from '../'
+beforeEach(() => {
+ // reset back to original config
+ configure({
+ queryAllElements: (element, query) => element.querySelectorAll(query),
+ })
+})
+
test('works without a global dom', async () => {
const container = new JSDOM(`
@@ -77,6 +85,194 @@ test('works without a browser context on a dom node (JSDOM Fragment)', () => {
`)
})
+test('works with a custom configured element query for shadow dom elements', async () => {
+ const window = new JSDOM(`
+
+
+
+
+
+ `).window
+ const document = window.document
+ const container = document.body
+
+ // create custom element as system under test
+ window.customElements.define(
+ 'example-input',
+ class extends window.HTMLElement {
+ constructor() {
+ super()
+ const shadow = this.attachShadow({mode: 'open'})
+
+ const div = document.createElement('div')
+ const label = document.createElement('label')
+ label.setAttribute('for', 'invisible-from-outer-dom')
+ label.innerHTML =
+ 'Visible in browser, invisible for traditional queries'
+ const input = document.createElement('input')
+ input.setAttribute('id', 'invisible-from-outer-dom')
+ div.appendChild(label)
+ div.appendChild(input)
+ shadow.appendChild(div)
+ }
+ },
+ )
+
+ // Given the default configuration is used
+
+ // When querying for the label
+ // Then it is not in the document
+ expect(
+ dtl.queryByLabelText(
+ container,
+ /Visible in browser, invisible for traditional queries/i,
+ ),
+ ).not.toBeInTheDocument()
+
+ // Given I have a naive query that allows searching shadow dom
+ const queryMeAndChildrenAndShadow = (element, query) => [
+ ...element.querySelectorAll(`:scope > ${query}`),
+ ...[...element.children].reduce(
+ (result, child) => [
+ ...result,
+ ...queryMeAndChildrenAndShadow(child, query),
+ ],
+ [],
+ ),
+ ...(element.shadowRoot?.querySelectorAll(query) ?? []),
+ ]
+
+ // When I configure the testing tools to use it
+ configure({
+ queryAllElements: queryMeAndChildrenAndShadow,
+ })
+
+ // Then it is part of the document
+ expect(
+ dtl.queryByLabelText(
+ container,
+ /Visible in browser, invisible for traditional queries/i,
+ ),
+ ).toBeInTheDocument()
+
+ // And it returns the expected item
+ expect(
+ dtl.getByLabelText(
+ container,
+ /Visible in browser, invisible for traditional queries/i,
+ ),
+ ).toMatchInlineSnapshot(`
+
+ `)
+})
+
+test('fails with a cross-boundary request for an element query for shadow dom elements with an appropriate error message', async () => {
+ const window = new JSDOM(`
+
+
+
+
+
+ `).window
+ const document = window.document
+ const container = document.body
+
+ window.customElements.define(
+ 'example-input',
+ class extends window.HTMLElement {
+ constructor() {
+ super()
+ const shadow = this.attachShadow({mode: 'open'})
+
+ const div = document.createElement('div')
+ const label = document.createElement('label')
+ label.setAttribute('for', 'invisible-from-outer-dom')
+ label.innerHTML =
+ 'Visible in browser, invisible for traditional queries'
+ const input = document.createElement('nested-example-input')
+ label.appendChild(input)
+ div.appendChild(label)
+ shadow.appendChild(div)
+ }
+ },
+ )
+
+ window.customElements.define(
+ 'nested-example-input',
+ class extends window.HTMLElement {
+ static formAssociated = true
+ constructor() {
+ super()
+ const shadow = this.attachShadow({mode: 'open'})
+
+ const form = document.createElement('form')
+ const input = document.createElement('input')
+ input.setAttribute('id', 'invisible-from-outer-dom')
+ input.setAttribute('type', 'text')
+ form.appendChild(input)
+ shadow.appendChild(form)
+ }
+ },
+ )
+
+ // Given I have a naive query that allows searching shadow dom
+ const queryDeep = (element, query) =>
+ [
+ ...element.querySelectorAll(`:scope > ${query}`),
+ ...(element.shadowRoot ? queryDeep(element.shadowRoot, query) : []),
+ ...[...element.children].reduce(
+ (result, child) => [...result, ...queryDeep(child, query)],
+ [],
+ ),
+ ].filter(item => !!item)
+ const queryAll = (element, query) => [
+ ...element.querySelectorAll(query),
+ ...queryDeep(element, query),
+ ]
+ const queryMeAndChildrenAndShadow = (_, query) =>
+ queryAll(document, query).find(element => !!element)
+ const queryMeAndChildrenAndShadowAll = (_, query) => [
+ ...queryAll(document, query),
+ ]
+
+ // When I configure the testing tools to use it
+ configure({
+ queryElement: queryMeAndChildrenAndShadow,
+ queryAllElements: queryMeAndChildrenAndShadowAll,
+ })
+
+ // Then it should throw an error informing me that the input is currently non-labelable
+ // While the error message is not correct, the scenario is currently mostly relevant when
+ // extending the query mechanism with shadow dom, so it's not something that the framework
+ // can foresee at the moment. https://github.com/WICG/webcomponents/issues/917 will hopefully
+ // resolve this issue
+ expect(() =>
+ dtl.getAllByLabelText(
+ container,
+ /Visible in browser, invisible for traditional queries/i,
+ ),
+ ).toThrowErrorMatchingInlineSnapshot(`
+ Found a label with the text of: /Visible in browser, invisible for traditional queries/i, however the element associated with this label () is non-labellable [https://html.spec.whatwg.org/multipage/forms.html#category-label]. If you really need to label a , you can use aria-label or aria-labelledby instead.
+
+ Ignored nodes: comments, ,
+
+
+
+ Object {
+ Symbol(SameObject caches): Object {
+ "children": HTMLCollection [],
+ },
+ }
+
+
+
+
+
+ `)
+})
+
test('byRole works without a global DOM', () => {
const {
window: {
diff --git a/src/config.ts b/src/config.ts
index 161e58f4..ec634d91 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,7 +1,17 @@
-import {Config, ConfigFn} from '../types/config'
+import {Config, ConfigFn, QueryAllElements, QueryElement} from '../types/config'
import {prettyDOM} from './pretty-dom'
type Callback = () => T
+
+const queryElement: QueryElement = (
+ element: T,
+ selector: string,
+) => element.querySelector(selector)
+const queryElementAll: QueryAllElements = (
+ element: T,
+ selector: string,
+) => element.querySelectorAll(selector)
+
interface InternalConfig extends Config {
_disableExpensiveErrorDiagnostics: boolean
}
@@ -48,6 +58,8 @@ let config: InternalConfig = {
},
_disableExpensiveErrorDiagnostics: false,
computedStyleSupportsPseudoElements: false,
+ queryElement,
+ queryAllElements: queryElementAll,
}
export function runWithExpensiveErrorDiagnosticsDisabled(
diff --git a/src/label-helpers.ts b/src/label-helpers.ts
index 4e1a3fed..35cfe586 100644
--- a/src/label-helpers.ts
+++ b/src/label-helpers.ts
@@ -1,5 +1,7 @@
import {TEXT_NODE} from './helpers'
+import {getConfig} from './config'
+
const labelledNodeNames = [
'button',
'meter',
@@ -43,7 +45,7 @@ function getRealLabels(element: Element) {
if (!isLabelable(element)) return []
- const labels = element.ownerDocument.querySelectorAll('label')
+ const labels = getConfig().queryAllElements(element.ownerDocument, 'label')
return Array.from(labels).filter(label => label.control === element)
}
@@ -63,7 +65,8 @@ function getLabels(
const labelsId = ariaLabelledBy ? ariaLabelledBy.split(' ') : []
return labelsId.length
? labelsId.map(labelId => {
- const labellingElement = container.querySelector(
+ const labellingElement = getConfig().queryElement(
+ container,
`[id="${labelId}"]`,
)
return labellingElement
@@ -75,7 +78,10 @@ function getLabels(
const formControlSelector =
'button, input, meter, output, progress, select, textarea'
const labelledFormControl = Array.from(
- label.querySelectorAll(formControlSelector),
+ getConfig().queryAllElements(
+ label,
+ formControlSelector,
+ ),
).filter(formControlElement => formControlElement.matches(selector))[0]
return {content: textToMatch, formControl: labelledFormControl}
})
diff --git a/src/queries/display-value.ts b/src/queries/display-value.ts
index 7177a84a..c77006d3 100644
--- a/src/queries/display-value.ts
+++ b/src/queries/display-value.ts
@@ -12,6 +12,7 @@ import {
fuzzyMatches,
makeNormalizer,
buildQueries,
+ getConfig,
} from './all-utils'
const queryAllByDisplayValue: AllByBoundAttribute = (
@@ -23,7 +24,10 @@ const queryAllByDisplayValue: AllByBoundAttribute = (
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(
- container.querySelectorAll(`input,textarea,select`),
+ getConfig().queryAllElements(
+ container,
+ `input,textarea,select`,
+ ),
).filter(node => {
if (node.tagName === 'SELECT') {
const selectedOptions = Array.from(
diff --git a/src/queries/label-text.ts b/src/queries/label-text.ts
index 39e766d5..1b6f96a6 100644
--- a/src/queries/label-text.ts
+++ b/src/queries/label-text.ts
@@ -1,4 +1,3 @@
-import {getConfig} from '../config'
import {checkContainerType} from '../helpers'
import {getLabels, getRealLabels, getLabelContent} from '../label-helpers'
import {
@@ -17,12 +16,18 @@ import {
makeSingleQuery,
wrapAllByQueryWithSuggestion,
wrapSingleQueryWithSuggestion,
+ getConfig,
} from './all-utils'
function queryAllLabels(
container: HTMLElement,
): {textToMatch: string | null; node: HTMLElement}[] {
- return Array.from(container.querySelectorAll('label,input'))
+ return Array.from(
+ getConfig().queryAllElements(
+ container,
+ 'label,input',
+ ),
+ )
.map(node => {
return {node, textToMatch: getLabelContent(node)}
})
@@ -56,7 +61,7 @@ const queryAllByLabelText: AllByText = (
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
const matchingLabelledElements = Array.from(
- container.querySelectorAll('*'),
+ getConfig().queryAllElements(container, '*'),
)
.filter(element => {
return (
@@ -169,7 +174,7 @@ function getTagNameOfElementAssociatedWithLabelViaFor(
return null
}
- const element = container.querySelector(`[id="${htmlFor}"]`)
+ const element = getConfig().queryElement(container, `[id="${htmlFor}"]`)
return element ? element.tagName.toLowerCase() : null
}
diff --git a/src/queries/role.js b/src/queries/role.js
index 5edd1c3c..6861a09e 100644
--- a/src/queries/role.js
+++ b/src/queries/role.js
@@ -104,7 +104,8 @@ function queryAllByRole(
}
return Array.from(
- container.querySelectorAll(
+ getConfig().queryAllElements(
+ container,
// Only query elements that can be matched by the following filters
makeRoleSelector(role, exact, normalizer ? matchNormalizer : undefined),
),
diff --git a/src/queries/text.ts b/src/queries/text.ts
index e0282844..4483bb2e 100644
--- a/src/queries/text.ts
+++ b/src/queries/text.ts
@@ -9,10 +9,10 @@ import {
import {
fuzzyMatches,
matches,
+ getConfig,
makeNormalizer,
getNodeText,
buildQueries,
- getConfig,
} from './all-utils'
const queryAllByText: AllByText = (
@@ -37,7 +37,12 @@ const queryAllByText: AllByText = (
return (
[
...baseArray,
- ...Array.from(container.querySelectorAll(selector)),
+ ...Array.from(
+ getConfig().queryAllElements(
+ container,
+ selector,
+ ),
+ ),
]
// TODO: `matches` according lib.dom.d.ts can get only `string` but according our code it can handle also boolean :)
.filter(node => !ignore || !node.matches(ignore as string))
diff --git a/src/queries/title.ts b/src/queries/title.ts
index 7366855f..85c6c5ec 100644
--- a/src/queries/title.ts
+++ b/src/queries/title.ts
@@ -9,6 +9,7 @@ import {
import {
fuzzyMatches,
matches,
+ getConfig,
makeNormalizer,
getNodeText,
buildQueries,
@@ -27,7 +28,10 @@ const queryAllByTitle: AllByBoundAttribute = (
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(
- container.querySelectorAll('[title], svg > title'),
+ getConfig().queryAllElements(
+ container,
+ '[title], svg > title',
+ ),
).filter(
node =>
matcher(node.getAttribute('title'), node, text, matchNormalizer) ||
diff --git a/src/query-helpers.ts b/src/query-helpers.ts
index 155210e1..0be41ea7 100644
--- a/src/query-helpers.ts
+++ b/src/query-helpers.ts
@@ -35,7 +35,10 @@ function queryAllByAttribute(
const matcher = exact ? matches : fuzzyMatches
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
return Array.from(
- container.querySelectorAll(`[${attribute}]`),
+ getConfig().queryAllElements(
+ container,
+ `[${attribute}]`,
+ ),
).filter(node =>
matcher(node.getAttribute(attribute), node, text, matchNormalizer),
)
diff --git a/types/config.d.ts b/types/config.d.ts
index c3617ef0..cf6ee309 100644
--- a/types/config.d.ts
+++ b/types/config.d.ts
@@ -1,3 +1,27 @@
+export type QueryElement = {
+ (container: T, selectors: K):
+ | HTMLElementTagNameMap[K]
+ | null
+ (container: T, selectors: K):
+ | SVGElementTagNameMap[K]
+ | null
+ (container: T, selectors: string): E | null
+}
+export type QueryAllElements = {
+ (
+ container: T,
+ selectors: K,
+ ): NodeListOf
+ (
+ container: T,
+ selectors: K,
+ ): NodeListOf
+ (
+ container: T,
+ selectors: string,
+ ): NodeListOf
+}
+
export interface Config {
testIdAttribute: string
/**
@@ -16,6 +40,14 @@ export interface Config {
defaultIgnore: string
showOriginalStackTrace: boolean
throwSuggestions: boolean
+ /**
+ * Returns the first element that is a descendant of node that matches selectors.
+ */
+ queryElement: QueryElement
+ /**
+ * Returns all element descendants of node that match selectors.
+ */
+ queryAllElements: QueryAllElements
getElementError: (message: string | null, container: Element) => Error
}