From aea0de232409e3cafadf7cccc1648a453f7401e6 Mon Sep 17 00:00:00 2001 From: Matthias Kainer Date: Wed, 13 Oct 2021 20:05:35 +0200 Subject: [PATCH 1/7] feat(queryconfig): Allows configuring a different element query mechanism Commit opens the possibility to configure the api to use more sophisticated queries on elements, for instance enabling shadow dom specific queries via libraries like https://github.com/Georgegriff/query-selector-shadow-dom --- src/__node_tests__/index.js | 47 +++++++++++++++++++++++++++++++++++ src/config.ts | 22 +++++++++++++++- src/label-helpers.ts | 12 ++++++--- src/queries/display-value.ts | 6 ++++- src/queries/label-text.ts | 13 +++++++--- src/queries/role.js | 3 ++- src/queries/text.ts | 8 +++++- src/queries/title.ts | 6 ++++- src/query-helpers.ts | 5 +++- types/__tests__/type-tests.ts | 10 ++++---- types/config.d.ts | 32 ++++++++++++++++++++++++ 11 files changed, 146 insertions(+), 18 deletions(-) diff --git a/src/__node_tests__/index.js b/src/__node_tests__/index.js index 6c663360..8a73eb2d 100644 --- a/src/__node_tests__/index.js +++ b/src/__node_tests__/index.js @@ -1,3 +1,4 @@ +import {configure} from '../config' import {JSDOM} from 'jsdom' import * as dtl from '../' @@ -77,6 +78,52 @@ test('works without a browser context on a dom node (JSDOM Fragment)', () => { `) }) +test('works with a custom configured element query', () => { + const container = JSDOM.fragment(` + + +
+ + + + + +
+
+
+ + + + + +
+
+ + + `) + + configure({ + queryAllElements: (element, query) => + element.querySelectorAll(`#other ${query}`), + }) + + expect(dtl.getByLabelText(container, /username/i)).toMatchInlineSnapshot(` + + `) + expect(dtl.getByLabelText(container, /password/i)).toMatchInlineSnapshot(` + + `) + // reset back to original config + configure({ + queryAllElements: (element, query) => element.querySelectorAll(query), + }) +}) + test('byRole works without a global DOM', () => { const { window: { diff --git a/src/config.ts b/src/config.ts index ed321155..0642c6db 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,27 @@ -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 + /** + * 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 } // It would be cleaner for this to live inside './queries', but @@ -46,6 +64,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 339647a6..2a18cb8a 100644 --- a/src/queries/role.js +++ b/src/queries/role.js @@ -100,7 +100,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 0e6ac3f7..0ec0517f 100644 --- a/src/queries/text.ts +++ b/src/queries/text.ts @@ -5,6 +5,7 @@ import {AllByText, GetErrorFunction} from '../../types' import { fuzzyMatches, matches, + getConfig, makeNormalizer, getNodeText, buildQueries, @@ -32,7 +33,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/__tests__/type-tests.ts b/types/__tests__/type-tests.ts index ad47e73d..a65866f5 100644 --- a/types/__tests__/type-tests.ts +++ b/types/__tests__/type-tests.ts @@ -40,14 +40,14 @@ export async function testQueries() { // screen queries screen.getByText('foo') - screen.getByText('foo') + screen.getByText('foo') screen.queryByText('foo') await screen.findByText('foo') await screen.findByText('foo', undefined, {timeout: 10}) screen.debug(screen.getAllByText('bar')) screen.queryAllByText('bar') await screen.findAllByText('bar') - await screen.findAllByRole('button', {name: 'submit'}) + await screen.findAllByRole('button', {name: 'submit'}) await screen.findAllByText('bar', undefined, {timeout: 10}) } @@ -249,11 +249,11 @@ export async function testWithin() { container.queryAllByLabelText('Some label') container.getByText('Click me') - container.getByText('Click me') - container.getAllByText('Click me') + container.getByText('Click me') + container.getAllByText('Click me') await container.findByRole('button', {name: /click me/i}) - container.getByRole('button', {name: /click me/i}) + container.getByRole('button', {name: /click me/i}) } /* diff --git a/types/config.d.ts b/types/config.d.ts index 6a3d1247..85bca811 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 /** @@ -14,6 +38,14 @@ export interface Config { defaultHidden: boolean 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 } From 091edc50488eedce4f1a76197a5b91eca21f78da Mon Sep 17 00:00:00 2001 From: Matthias Kainer Date: Thu, 14 Oct 2021 09:11:34 +0200 Subject: [PATCH 2/7] feat(queryconfig): create a test showing shadow dom implementation --- src/__node_tests__/index.js | 112 +++++++++++++++++++++++++----------- 1 file changed, 78 insertions(+), 34 deletions(-) diff --git a/src/__node_tests__/index.js b/src/__node_tests__/index.js index 8a73eb2d..cfb9e847 100644 --- a/src/__node_tests__/index.js +++ b/src/__node_tests__/index.js @@ -1,7 +1,14 @@ -import {configure} from '../config' 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(` @@ -78,50 +85,87 @@ test('works without a browser context on a dom node (JSDOM Fragment)', () => { `) }) -test('works with a custom configured element query', () => { - const container = 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(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: (element, query) => - element.querySelectorAll(`#other ${query}`), + queryAllElements: queryMeAndChildrenAndShadow, }) - expect(dtl.getByLabelText(container, /username/i)).toMatchInlineSnapshot(` - - `) - expect(dtl.getByLabelText(container, /password/i)).toMatchInlineSnapshot(` + // 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(` `) - // reset back to original config - configure({ - queryAllElements: (element, query) => element.querySelectorAll(query), - }) }) test('byRole works without a global DOM', () => { From 80a01feafa8e0054ede8a59b57af0e6dea184fa6 Mon Sep 17 00:00:00 2001 From: Matthias Kainer Date: Tue, 16 Nov 2021 17:17:29 +0100 Subject: [PATCH 3/7] feat(queryconfig): removed queryElement from internal config --- src/config.ts | 8 -------- types/config.d.ts | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/config.ts b/src/config.ts index 0642c6db..1baabe72 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,14 +14,6 @@ const queryElementAll: QueryAllElements = ( interface InternalConfig extends Config { _disableExpensiveErrorDiagnostics: 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 } // It would be cleaner for this to live inside './queries', but diff --git a/types/config.d.ts b/types/config.d.ts index 85bca811..5dedd53c 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -41,11 +41,11 @@ export interface Config { /** * Returns the first element that is a descendant of node that matches selectors. */ - queryElement?: QueryElement + queryElement: QueryElement /** * Returns all element descendants of node that match selectors. */ - queryAllElements?: QueryAllElements + queryAllElements: QueryAllElements getElementError: (message: string | null, container: Element) => Error } From 40b849c6e309046c1564481cb9709e26d55d499e Mon Sep 17 00:00:00 2001 From: Matthias Kainer Date: Tue, 16 Nov 2021 19:58:13 +0100 Subject: [PATCH 4/7] feat(queryconfig): Add scope to query extension for test By adding ":scope >" to the css selector we avoid returning duplicate results if anybody wants to extend this test in the future. --- src/__node_tests__/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__node_tests__/index.js b/src/__node_tests__/index.js index cfb9e847..95857b95 100644 --- a/src/__node_tests__/index.js +++ b/src/__node_tests__/index.js @@ -131,7 +131,7 @@ test('works with a custom configured element query for shadow dom elements', asy // Given I have a naive query that allows searching shadow dom const queryMeAndChildrenAndShadow = (element, query) => [ - ...element.querySelectorAll(query), + ...element.querySelectorAll(`:scope > ${query}`), ...[...element.children].reduce( (result, child) => [ ...result, From df77ec386ca0e34ae665c791a8d536a31245d11c Mon Sep 17 00:00:00 2001 From: Matthias Kainer Date: Tue, 16 Nov 2021 20:23:15 +0100 Subject: [PATCH 5/7] feat(queryconfig): Reverted removal of generic types --- types/__tests__/type-tests.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/types/__tests__/type-tests.ts b/types/__tests__/type-tests.ts index a65866f5..ad47e73d 100644 --- a/types/__tests__/type-tests.ts +++ b/types/__tests__/type-tests.ts @@ -40,14 +40,14 @@ export async function testQueries() { // screen queries screen.getByText('foo') - screen.getByText('foo') + screen.getByText('foo') screen.queryByText('foo') await screen.findByText('foo') await screen.findByText('foo', undefined, {timeout: 10}) screen.debug(screen.getAllByText('bar')) screen.queryAllByText('bar') await screen.findAllByText('bar') - await screen.findAllByRole('button', {name: 'submit'}) + await screen.findAllByRole('button', {name: 'submit'}) await screen.findAllByText('bar', undefined, {timeout: 10}) } @@ -249,11 +249,11 @@ export async function testWithin() { container.queryAllByLabelText('Some label') container.getByText('Click me') - container.getByText('Click me') - container.getAllByText('Click me') + container.getByText('Click me') + container.getAllByText('Click me') await container.findByRole('button', {name: /click me/i}) - container.getByRole('button', {name: /click me/i}) + container.getByRole('button', {name: /click me/i}) } /* From eabc513519fe0a690450e0ef1b036f4707ad829d Mon Sep 17 00:00:00 2001 From: Matthias Kainer Date: Tue, 16 Nov 2021 20:54:53 +0100 Subject: [PATCH 6/7] feat(queryconfig): Adding error test for cross-shadom-dom roles --- src/__node_tests__/index.js | 105 ++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/__node_tests__/index.js b/src/__node_tests__/index.js index 95857b95..6714639c 100644 --- a/src/__node_tests__/index.js +++ b/src/__node_tests__/index.js @@ -168,6 +168,111 @@ test('works with a custom configured element query for shadow dom elements', asy `) }) +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,