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

#911@minor: Adds support for "i" and "s" modifiers to attribute query… #913

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions packages/happy-dom/src/query-selector/ISelectorAttribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ export default interface ISelectorAttribute {
name: string;
operator: string | null;
value: string | null;
modifier: 's' | 'i' | null;
regExp: RegExp | null;
}
50 changes: 7 additions & 43 deletions packages/happy-dom/src/query-selector/SelectorItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,49 +236,13 @@ export default class SelectorItem {

priorityWeight += 10;

if (attribute.value !== null) {
if (elementAttribute.value === null) {
return null;
}

switch (attribute.operator) {
// [attribute~="value"] - Contains a specified word.
case null:
if (attribute.value !== elementAttribute.value) {
return null;
}
break;
// [attribute~="value"] - Contains a specified word.
case '~':
if (!elementAttribute.value.split(' ').includes(attribute.value)) {
return null;
}
break;
// [attribute|="value"] - Starts with the specified word.
case '|':
if (!new RegExp(`^${attribute.value}[- ]`).test(elementAttribute.value)) {
return null;
}
break;
// [attribute^="value"] - Begins with a specified value.
case '^':
if (!elementAttribute.value.startsWith(attribute.value)) {
return null;
}
break;
// [attribute$="value"] - Ends with a specified value.
case '$':
if (!elementAttribute.value.endsWith(attribute.value)) {
return null;
}
break;
// [attribute*="value"] - Contains a specified value.
case '*':
if (!elementAttribute.value.includes(attribute.value)) {
return null;
}
break;
}
if (
attribute.value !== null &&
(elementAttribute.value === null ||
(attribute.regExp && !attribute.regExp.test(elementAttribute.value)) ||
(!attribute.regExp && attribute.value !== elementAttribute.value))
) {
return null;
}
}

Expand Down
92 changes: 72 additions & 20 deletions packages/happy-dom/src/query-selector/SelectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,17 @@ import ISelectorPseudo from './ISelectorPseudo';
* Group 6: Attribute name when there is a value using apostrophe (e.g. "attr1")
* Group 7: Attribute operator when using apostrophe (e.g. "~")
* Group 8: Attribute value when using apostrophe (e.g. "value1")
* Group 9: Attribute name when threre is a value not using apostrophe (e.g. "attr1")
* Group 10: Attribute operator when not using apostrophe (e.g. "~")
* Group 11: Attribute value when notusing apostrophe (e.g. "value1")
* Group 12: Pseudo name when arguments (e.g. "nth-child")
* Group 13: Arguments of pseudo (e.g. "2n + 1")
* Group 14: Pseudo name when no arguments (e.g. "empty")
* Group 15: Combinator.
* Group 9: Attribute modifier when using apostrophe (e.g. "i" or "s")
* Group 10: Attribute name when threre is a value not using apostrophe (e.g. "attr1")
* Group 11: Attribute operator when not using apostrophe (e.g. "~")
* Group 12: Attribute value when notusing apostrophe (e.g. "value1")
* Group 13: Pseudo name when arguments (e.g. "nth-child")
* Group 14: Arguments of pseudo (e.g. "2n + 1")
* Group 15: Pseudo name when no arguments (e.g. "empty")
* Group 16: Combinator.
*/
const SELECTOR_REGEXP =
/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_]+)\]|\[([a-zA-Z0-9-_]+)([~|^$*]{0,1}) *= *["']{1}([^"']*)["']{1}\]|\[([a-zA-Z0-9-_]+)([~|^$*]{0,1}) *= *([^\]]*)\]|:([a-zA-Z-]+) *\(([^)]+)\)|:([a-zA-Z-]+)|([ ,+>]*)/g;
/(\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_]|\\.)+)|\.((?:[a-zA-Z0-9-_]|\\.)+)|\[([a-zA-Z0-9-_]+)\]|\[([a-zA-Z0-9-_]+) *([~|^$*]{0,1}) *= *["']{1}([^"']*)["']{1} *(s|i){0,1}\]|\[([a-zA-Z0-9-_]+) *([~|^$*]{0,1}) *= *([^\]]*)\]|:([a-zA-Z-]+) *\(([^)]+)\)|:([a-zA-Z-]+)|([ ,+>]*)/g;

/**
* Escaped Character RegExp.
Expand Down Expand Up @@ -117,30 +118,40 @@ export default class SelectorParser {
currentSelectorItem.attributes.push({
name: match[5].toLowerCase(),
operator: null,
value: null
value: null,
modifier: null,
regExp: null
});
} else if (match[6] && match[8] !== undefined) {
currentSelectorItem.attributes = currentSelectorItem.attributes || [];
currentSelectorItem.attributes.push({
name: match[6].toLowerCase(),
operator: match[7] || null,
value: match[8]
value: match[8],
modifier: match[9] || null,
regExp: this.getAttributeRegExp({
operator: match[7],
value: match[8],
modifier: match[9]
})
});
} else if (match[9] && match[11] !== undefined) {
} else if (match[10] && match[12] !== undefined) {
currentSelectorItem.attributes = currentSelectorItem.attributes || [];
currentSelectorItem.attributes.push({
name: match[9].toLowerCase(),
operator: match[10] || null,
value: match[11]
name: match[10].toLowerCase(),
operator: match[11] || null,
value: match[12],
modifier: null,
regExp: this.getAttributeRegExp({ operator: match[7], value: match[8] })
});
} else if (match[12] && match[13]) {
} else if (match[13] && match[14]) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[12], match[13]));
} else if (match[14]) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[14]));
currentSelectorItem.pseudos.push(this.getPseudo(match[13], match[14]));
} else if (match[15]) {
switch (match[15].trim()) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[15]));
} else if (match[16]) {
switch (match[16].trim()) {
case ',':
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.descendant
Expand Down Expand Up @@ -178,6 +189,47 @@ export default class SelectorParser {
return groups;
}

/**
* Returns attribute RegExp.
*
* @param attribute Attribute.
* @param attribute.value Attribute value.
* @param attribute.operator Attribute operator.
* @param attribute.modifier Attribute modifier.
* @returns Attribute RegExp.
*/
private static getAttributeRegExp(attribute: {
value?: string;
operator?: string;
modifier?: string;
}): RegExp | null {
const modifier = attribute.modifier === 'i' ? 'i' : '';

if (!attribute.operator || !attribute.value) {
return null;
}

switch (attribute.operator) {
// [attribute~="value"] - Contains a specified word.
case '~':
return new RegExp(`[- ]${attribute.value}|${attribute.value}[- ]`, modifier);
Copy link
Contributor

Choose a reason for hiding this comment

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

This change seems to affect testing-library getByRole("menu") which translates to *[role~="menu"] selector.

Regular expression now expects either dash or space to be always present. Probably the same issue for | operator below.

// [attribute|="value"] - Starts with the specified word.
case '|':
return new RegExp(`^${attribute.value}[- ]`, modifier);
// [attribute^="value"] - Begins with a specified value.
case '^':
return new RegExp(`^${attribute.value}`, modifier);
// [attribute$="value"] - Ends with a specified value.
case '$':
return new RegExp(`${attribute.value}$`, modifier);
// [attribute*="value"] - Contains a specified value.
case '*':
return new RegExp(`${attribute.value}`, modifier);
default:
return null;
}
}

/**
* Returns pseudo.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,15 @@ describe('QuerySelector', () => {
).toEqual(['div.n3', 'div.n6', 'div.n9']);
});

it('Returns all elements matching "a[href]:not([href *= "javascript:" i])".', () => {
const container = document.createElement('div');
container.innerHTML = `<a href="JAVASCRIPT:alert(1)">Link</a><a href="https://example.com">Link</a>`;
const elements = container.querySelectorAll('a[href]:not([href *= "javascript:" i])');

expect(elements.length).toBe(1);
expect(elements[0] === container.children[1]).toBe(true);
});

it('Returns all elements matching ":nth-child(odd)".', () => {
const container = document.createElement('div');
container.innerHTML = QuerySelectorNthChildHTML;
Expand Down