Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: suggestions for which query to use #586

Merged
merged 34 commits into from May 29, 2020
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0cf9fd9
feat: suggestions for which query to use
May 25, 2020
7cb52ba
coverage bumped
May 25, 2020
e0b7f69
Merge branch 'master' into feature/suggestions
benmonro May 25, 2020
75c492c
more tests, label text working
May 25, 2020
51c377c
more cases for labelText
May 25, 2020
be480ad
removed commented out code
May 25, 2020
63b26cb
added *ByDisplayValue
May 25, 2020
a0dc8f9
all queries supported now
May 26, 2020
5ee9649
cleanup
May 26, 2020
625ce15
added types for suggestions
May 26, 2020
1db13e6
export suggestions from index
May 26, 2020
30e64e2
fixed a couple lint warnings
May 26, 2020
e3bdcff
Update src/__tests__/suggestions.js
benmonro May 29, 2020
a9394b0
Update src/config.js
benmonro May 29, 2020
b496f09
Update src/__tests__/suggestions.js
benmonro May 29, 2020
32883e7
Update src/config.js
benmonro May 29, 2020
7354bc7
Update src/suggestions.js
benmonro May 29, 2020
effdd92
Update src/query-helpers.js
benmonro May 29, 2020
0d5c6e4
PR feedback
May 29, 2020
7ec23c2
refactor to getLabelFor
May 29, 2020
515bee0
Merge branch 'feature/suggestions' of https://github.com/benmonro/dom…
benmonro May 29, 2020
cf78610
formatting
benmonro May 29, 2020
042bd93
added support for suggest:false
benmonro May 29, 2020
e536d05
case ignored regex
benmonro May 29, 2020
8fb28da
using full query name for get & getAll & query
benmonro May 29, 2020
18b1813
suggest on labeltext
benmonro May 29, 2020
2a741e9
suggest on queryAllBy
benmonro May 29, 2020
ac9e9df
more tests
benmonro May 29, 2020
53460df
rename showSuggs to throwSuggs
benmonro May 29, 2020
fa69912
PR feedback
benmonro May 29, 2020
8861c24
Merge branch 'master' into feature/suggestions
benmonro May 29, 2020
7ec0b97
matches.d.ts
benmonro May 29, 2020
51618bd
Merge branch 'feature/suggestions' of https://github.com/benmonro/dom…
benmonro May 29, 2020
9cec773
Update types/matches.d.ts
benmonro May 29, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
175 changes: 175 additions & 0 deletions src/__tests__/suggestions.js
@@ -0,0 +1,175 @@
import {configure} from '../config'
import {screen} from '..'
import {renderIntoDocument} from './helpers/test-utils'

beforeEach(() => {
configure({showSuggestions: true})
})
benmonro marked this conversation as resolved.
Show resolved Hide resolved
it('should not suggest when using getByRole', () => {
benmonro marked this conversation as resolved.
Show resolved Hide resolved
renderIntoDocument(`<button data-testid="foo">submit</button>`)

expect(() => screen.getByRole('button', {name: /submit/})).not.toThrowError()
})

test('should not suggest when nothing available', () => {
renderIntoDocument(`<span data-testid="foo" />`)

expect(() => screen.queryByTestId('foo')).not.toThrowError()
})

test(`should not suggest if the suggestion would give different results`, () => {
renderIntoDocument(`
<input type="text" data-testid="foo" /><span data-testid="foo" />
`)

expect(() => screen.getAllByTestId('foo')).not.toThrowError()
})

test('should suggest getByRole when used with getBy', () => {
renderIntoDocument(`<button data-testid="foo">submit</button>`)

expect(() => screen.getByTestId('foo')).toThrowErrorMatchingInlineSnapshot(`
"A better query is available, try this:
*ByRole("button", {name:/submit/})
benmonro marked this conversation as resolved.
Show resolved Hide resolved


<body>
<button
data-testid="foo"
>
submit
</button>
</body>"
`)
})

test('should suggest *ByRole when used with getAllBy', () => {
renderIntoDocument(`
<button data-testid="foo">submit</button>
<button data-testid="foo">submit</button>`)

expect(() => screen.getAllByTestId('foo'))
.toThrowErrorMatchingInlineSnapshot(`
"A better query is available, try this:
*ByRole("button", {name:/submit/})
benmonro marked this conversation as resolved.
Show resolved Hide resolved


<body>


<button
data-testid="foo"
>
submit
</button>


<button
data-testid="foo"
>
submit
</button>
</body>"
`)
})

test('should suggest img role w/ alt text', () => {
renderIntoDocument(`<img data-testid="img" alt="Incredibles 2 Poster" />`)

expect(() => screen.getByAltText('Incredibles 2 Poster')).toThrowError(
/\*ByRole\("img", \{name:\/Incredibles 2 Poster\/\}\)/,
)
})

test('should suggest *ByLabelText when no role available', () => {
renderIntoDocument(
`<label for="foo">Username</label><input data-testid="foo" id="foo" />`,
)
expect(() => screen.getByTestId('foo')).toThrowError(
/\*ByLabelText\("Username"\)/,
)
})

test(`should suggest *ByLabel on non form elements`, () => {
renderIntoDocument(`
<div data-testid="foo" aria-labelledby="section-one-header">
<span id="section-one-header">Section One</span>
<p>some content</p>
</div>
`)

expect(() => screen.getByTestId('foo')).toThrowError(
/\*ByLabelText\("Section One"\)/,
)
})

test.each([
`<label id="username-label">Username</label><input aria-labelledby="username-label" type="text" />`,
`<label><span>Username</span><input type="text" /></label>`,
`<label for="foo">Username</label><input id="foo" type="text" />`,
])('should suggest *ByRole over label %s', html => {
renderIntoDocument(html)

expect(() => screen.getByLabelText('Username')).toThrowError(
/\*ByRole\("textbox", \{name:\/Username\/\}\)/,
)
})

test(`should suggest *ByPlaceholderText`, () => {
renderIntoDocument(`<input data-testid="foo" placeholder="Username" />`)

expect(() => screen.getByTestId('foo')).toThrowError(
/\*ByPlaceholderText\("Username"\)/,
)
})

test(`should suggest *ByText for simple elements`, () => {
renderIntoDocument(`<div data-testid="foo">hello there</div>`)

expect(() => screen.getByTestId('foo')).toThrowError(
/\*ByText\("hello there"\)/,
)
})

test(`should suggest *ByDisplayValue`, () => {
renderIntoDocument(`<input id="lastName" data-testid="lastName" />`)

document.getElementById('lastName').value = 'Prine' // RIP John Prine

expect(() => screen.getByTestId('lastName')).toThrowError(
/\*ByDisplayValue\("Prine"\)/,
)
})

test(`should suggest *ByAltText`, () => {
renderIntoDocument(`
<input data-testid="input" alt="last name" />
<map name="workmap">
<area data-testid="area" shape="rect" coords="34,44,270,350" alt="Computer">
</map>
`)

expect(() => screen.getByTestId('input')).toThrowError(
/\*ByAltText\("last name"\)/,
)
expect(() => screen.getByTestId('area')).toThrowError(
/\*ByAltText\("Computer"\)/,
)
})

test(`should suggest *ByTitle`, () => {
renderIntoDocument(`
<span title="Delete" data-testid="delete"></span>
<svg>
<title data-testid="svg">Close</title>
<g><path /></g>
</svg>`)

expect(() => screen.getByTestId('delete')).toThrowError(
/\*ByTitle\("Delete"\)/,
)

//Since `ByTitle` and `ByText` will both return the <title> element
//`getByText` will always be the suggested query as it is higher up the list.
benmonro marked this conversation as resolved.
Show resolved Hide resolved
expect(() => screen.getByTestId('svg')).toThrowError(/\*ByText\("Close"\)/)
})
3 changes: 3 additions & 0 deletions src/config.js
Expand Up @@ -19,6 +19,9 @@ let config = {
//showOriginalStackTrace flag to show the full error stack traces for async errors
benmonro marked this conversation as resolved.
Show resolved Hide resolved
showOriginalStackTrace: false,

//throw errors w/ suggestions for better queries. opt in so off by default.
benmonro marked this conversation as resolved.
Show resolved Hide resolved
showSuggestions: false,

// called when getBy* queries fail. (message, container) => Error
getElementError(message, container) {
const error = new Error(
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Expand Up @@ -16,6 +16,7 @@ export * from './query-helpers'
export {getRoles, logRoles, isInaccessible} from './role-helpers'
export * from './pretty-dom'
export {configure} from './config'
export * from './suggestions'

export {
// "within" reads better in user-code
Expand Down
5 changes: 3 additions & 2 deletions src/queries/placeholder-text.js
@@ -1,7 +1,8 @@
import {queryAllByAttribute, buildQueries} from './all-utils'

const queryAllByPlaceholderText = queryAllByAttribute.bind(null, 'placeholder')

function queryAllByPlaceholderText(...args) {
return queryAllByAttribute('placeholder', ...args)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ICYW: all of the other queries use named functions and the suggestion feature needs them to be named.

const getMultipleError = (c, text) =>
`Found multiple elements with the placeholder text of: ${text}`
const getMissingError = (c, text) =>
Expand Down
5 changes: 3 additions & 2 deletions src/queries/test-id.js
Expand Up @@ -2,8 +2,9 @@ import {queryAllByAttribute, getConfig, buildQueries} from './all-utils'

const getTestIdAttribute = () => getConfig().testIdAttribute

const queryAllByTestId = (...args) =>
queryAllByAttribute(getTestIdAttribute(), ...args)
function queryAllByTestId(...args) {
return queryAllByAttribute(getTestIdAttribute(), ...args)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ICYW: all of the other queries use named functions and the suggestion feature needs them to be named.


const getMultipleError = (c, id) =>
`Found multiple elements by: [${getTestIdAttribute()}="${id}"]`
Expand Down
39 changes: 38 additions & 1 deletion src/query-helpers.js
@@ -1,3 +1,4 @@
import {getSuggestedQuery} from './suggestions'
import {fuzzyMatches, matches, makeNormalizer} from './matches'
import {waitFor} from './wait-for'
import {getConfig} from './config'
Expand Down Expand Up @@ -49,10 +50,27 @@ function makeSingleQuery(allQuery, getMultipleError) {
container,
)
}
return els[0] || null
const element = els[0] || null

if (getConfig().showSuggestions) {
const suggestion = getSuggestedQuery(element)
if (suggestion && !allQuery.name.endsWith(suggestion.queryName)) {
throw getSuggestionError(suggestion.toString(), container)
}
}
return element
benmonro marked this conversation as resolved.
Show resolved Hide resolved
}
}

function getSuggestionError(suggestion, container) {
return getConfig().getElementError(
`A better query is available, try this:
*By${suggestion.toString()}
`,
container,
)
}

// this accepts a query function and returns a function which throws an error
// if an empty list of elements is returned
function makeGetAllQuery(allQuery, getMissingError) {
Expand All @@ -64,6 +82,21 @@ function makeGetAllQuery(allQuery, getMissingError) {
container,
)
}
if (getConfig().showSuggestions) {
//get a unique list of all suggestion messages. We are only going to make a suggestion if
benmonro marked this conversation as resolved.
Show resolved Hide resolved
// all the suggestions are the same
const uniqueSuggestionMessages = [
...new Set(els.map(element => getSuggestedQuery(element)?.toString())),
]

if (
// only want to suggest if all the els have the same suggestion.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call.

uniqueSuggestionMessages.length === 1 &&
!allQuery.name.endsWith(getSuggestedQuery(els[0]).queryName)
) {
throw getSuggestionError(uniqueSuggestionMessages[0], container)
}
}
return els
}
}
Expand All @@ -78,6 +111,10 @@ function makeFindQuery(getter) {
function buildQueries(queryAllBy, getMultipleError, getMissingError) {
const queryBy = makeSingleQuery(queryAllBy, getMultipleError)
const getAllBy = makeGetAllQuery(queryAllBy, getMissingError)
// Suggestions need to know how they're being used, so need to set the name of the allQuery
Object.defineProperty(getAllBy, 'name', {
value: queryAllBy.name.replace('query', 'get'),
})
const getBy = makeSingleQuery(getAllBy, getMultipleError)
const findAllBy = makeFindQuery(getAllBy)
const findBy = makeFindQuery(getBy)
Expand Down
83 changes: 83 additions & 0 deletions src/suggestions.js
@@ -0,0 +1,83 @@
import {computeAccessibleName} from 'dom-accessibility-api'
import {getRoles} from './role-helpers'
import {getDefaultNormalizer} from './matches'

const normalize = getDefaultNormalizer()

function getLabelTextFor(element) {
let label

const allLabels = Array.from(document.querySelectorAll('label'))

label = allLabels.find(lbl => lbl.control === element)

if (!label) {
const ariaLabelledBy = element.getAttribute('aria-labelledby')

if (ariaLabelledBy) {
// we're using this notation because with the # selector we would have to escape special characters e.g. user.name
// see https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector#Escaping_special_characters
label = document.querySelector(`[id=${ariaLabelledBy}]`)
}
}

benmonro marked this conversation as resolved.
Show resolved Hide resolved
if (label) {
return label.textContent
}

return undefined
}

function makeSuggestion(queryName, content, name) {
return {
queryName,
toString() {
const options = name ? `, {name:/${name}/}` : ''
benmonro marked this conversation as resolved.
Show resolved Hide resolved
return `${queryName}("${content}"${options})`
},
}
}

export function getSuggestedQuery(element) {
const roles = getRoles(element)

const roleNames = Object.keys(roles)
const name = computeAccessibleName(element)
if (roleNames.length) {
const [role] = roleNames
return makeSuggestion('Role', role, name)
}
benmonro marked this conversation as resolved.
Show resolved Hide resolved

const labelText = getLabelTextFor(element)
if (labelText) {
return makeSuggestion('LabelText', labelText)
}

const placeholderText = element.getAttribute('placeholder')
if (placeholderText) {
return makeSuggestion('PlaceholderText', placeholderText)
}

let {textContent} = element
textContent = normalize(textContent)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let {textContent} = element
textContent = normalize(textContent)
const textContent = normalize(element.textContent)

if (textContent) {
return makeSuggestion('Text', textContent)
}

if (element.value) {
return makeSuggestion('DisplayValue', normalize(element.value))
}

const alt = element.getAttribute('alt')
if (alt) {
return makeSuggestion('AltText', alt)
}

const title = element.getAttribute('title')

if (title) {
return makeSuggestion('Title', title)
}

return undefined
}
1 change: 1 addition & 0 deletions types/index.d.ts
Expand Up @@ -22,3 +22,4 @@ export * from './get-queries-for-element';
export * from './pretty-dom';
export * from './role-helpers';
export * from './config';
export * from './suggestions';
6 changes: 6 additions & 0 deletions types/suggestions.d.ts
@@ -0,0 +1,6 @@
export interface Suggestion {
queryName: string
toString(): string
}

export function getSuggestedQuery(element: HTMLElement): Suggestion | undefined