Skip to content

Commit

Permalink
feat: support partial attribute value matches
Browse files Browse the repository at this point in the history
  • Loading branch information
aldeed committed Sep 10, 2020
1 parent 84c67f7 commit 62f749c
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 23 deletions.
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Expand Up @@ -51,6 +51,7 @@
"create-qawolf": "^1.2.0",
"debug": "*",
"glob": "^7.1.6",
"html-tags": "^3.1.0",
"inquirer": "^7.3.3",
"kleur": "^4.0.3",
"open": "^7.1.0",
Expand Down
12 changes: 9 additions & 3 deletions packages/sandbox/src/pages/CheckboxInputs/HtmlCheckboxInputs.js
Expand Up @@ -16,12 +16,18 @@ function HtmlCheckboxInputs() {
<input id="sadovh89r" type="checkbox" name="nonDynamicInput" />
<label htmlFor="sadovh89r"> Checkbox with non-dynamic name</label>
<br />
<input id="sdf980ergm" type="checkbox" name="input-bu32879fDi" />
<label htmlFor="sdf980ergm"> Checkbox with half-dynamic name</label>
<br />
<input id="b98joifbon" type="checkbox" name="bu32879fDi" />
<label htmlFor="b98joifbon"> Checkbox with dynamic name</label>
<br />
<input id="v9eonirh894" type="checkbox" name="input-bu32879fDi" />
<label htmlFor="v9eonirh894"> Checkbox with dynamic ending of name</label>
<br />
<input id="ern84j8g0" type="checkbox" name="bu32879fDi-check" />
<label htmlFor="ern84j8g0"> Checkbox with dynamic beginning of name</label>
<br />
<input id="fdg8e9v4" type="checkbox" name="f89ndrn4-blue-5" />
<label htmlFor="fdg8e9v4"> Checkbox with dynamic beginning and ending of name</label>
<br />
<input id="y908drgun4" type="checkbox" name="" />
<label htmlFor="y908drgun4"> Checkbox with empty name</label>
<br />
Expand Down
20 changes: 16 additions & 4 deletions src/web/cues.ts
@@ -1,8 +1,8 @@
import { getAttribute } from './attribute';
import { getElementText } from './selectorEngine';
import { isDynamic } from './isDynamic';
import { getValueMatchSelector, isDynamic } from './isDynamic';

const DYNAMIC_VALUE_OK_ATTRIBUTES = ['href', 'src'];
const DYNAMIC_VALUE_OK_ATTRIBUTES = ['href', 'src', 'value'];

export type Cue = {
level: number; // 0 is target, 1 is parent, etc.
Expand All @@ -19,6 +19,7 @@ export type BuildCues = {

type CueTypeConfig = {
elements: string[];
isPreferred?: boolean;
penalty: number;
};

Expand Down Expand Up @@ -129,6 +130,7 @@ export const getCueTypesConfig = (attributes: string[]): CueTypesConfig => {
attributes.forEach((attribute) => {
preferredAttributes[attribute] = {
elements: ['*'],
isPreferred: true,
penalty: 0,
};
}, {});
Expand Down Expand Up @@ -183,7 +185,7 @@ export const buildCuesForElement = ({
}

const cues: Cue[] = Object.keys(cueTypesConfig).reduce((list, cueType) => {
const { elements, penalty } = cueTypesConfig[cueType];
const { elements, isPreferred, penalty } = cueTypesConfig[cueType];

// First find out whether this cue type is relevant for this element
if (!elements.some((selector: string) => element.matches(selector))) {
Expand Down Expand Up @@ -244,13 +246,23 @@ export const buildCuesForElement = ({
});
if (attributeValuePair) {
const { name, value } = attributeValuePair;
if (value.length && (DYNAMIC_VALUE_OK_ATTRIBUTES.includes(name) || !isDynamic(value))) {
if (value.length && (isPreferred || DYNAMIC_VALUE_OK_ATTRIBUTES.includes(name))) {
list.push({
level,
penalty,
type: 'attribute',
value: `[${name}="${value}"]`,
});
} else {
const { match, operator } = getValueMatchSelector(value) || {};
if (match) {
list.push({
level,
penalty,
type: 'attribute',
value: `[${name}${operator}"${match}"]`,
});
}
}
}
break;
Expand Down
141 changes: 127 additions & 14 deletions src/web/isDynamic.ts
@@ -1,20 +1,47 @@
import htmlTags from 'html-tags';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const englishWords = require('an-array-of-english-words/index.json');

type ValueMatchSelectorType =
| 'startsWith'
| 'endsWith'
| 'contains'
| 'equals';

type ValueMatchOperator =
| '^='
| '$='
| '*='
| '=';

type ValueMatchSelector = {
match: string;
operator: ValueMatchOperator;
type: ValueMatchSelectorType;
startPosition: number;
};

const matchOperators = new Map<ValueMatchSelectorType, ValueMatchOperator>([
['startsWith', '^='],
['endsWith', '$='],
['contains', '*='],
['equals', '='],
]);

// Keep these two in sync
const SPLIT_CHARACTERS = [' ', '-', '_', ':'];
const SPLIT_REGEXP = /[ \-_:]+/;

const allWords = new Set([
'btn',
'checkbox',
'col',
'div',
'dropdown',
// favicon
'fa',
'grid',
'html',
'img',
'inputtext',
'lg',
'li',
'login',
'logout',
// medium
Expand All @@ -26,10 +53,9 @@ const allWords = new Set([
'signout',
'signup',
'sm',
'svg',
'textinput',
'todo',
'ul',
...htmlTags,
...englishWords,
]);

Expand All @@ -42,18 +68,22 @@ export const getTokens = (value: string): string[] => {
const tokens = [];

// split by space, dash, underscore, colon
value.split(/[ \-_:]+/).forEach((token) => {
value.split(SPLIT_REGEXP).forEach((token) => {
if (token.match(/\d/)) {
tokens.push(token);
} else {
// split by and camel case when there are no numbers
// split by camel case when there are no numbers
tokens.push(...token.split(/(?=[A-Z])/));
}
});

return tokens.map((token) => token.toLowerCase());
};

export const tokenIsDynamic = (value: string): boolean => {
return !allWords.has(value);
};

export const isDynamic = (
value: string,
threshold = SCORE_THRESHOLD,
Expand All @@ -62,11 +92,94 @@ export const isDynamic = (

const tokens = getTokens(value);

const words = tokens.filter((token) => allWords.has(token));
const dynamicTokens = tokens.filter(tokenIsDynamic);

// if there are 2 or more dynamic tokens, mark it as dynamic
if (dynamicTokens.length >= 2) return true;

// If half or more tokens are dynamic, mark value as dynamic
return dynamicTokens.length / tokens.length >= threshold;
};

export const getValueMatchSelector = (
value: string,
): ValueMatchSelector | null => {
if (!value || typeof value !== 'string' || value.length === 0) return null;

const tokens = getTokens(value);

let currentPosition = 0;
let currentSubstring = '';
let blockCount = 0;
let lastTokenType: string;
let longestSubstring = '';
let longestSubstringStart = 0;
let type: ValueMatchSelectorType;

const checkLongest = (isEnd = false): void => {
if (currentSubstring.length > longestSubstring.length) {
longestSubstring = currentSubstring;
longestSubstringStart = currentPosition - currentSubstring.length;

if (longestSubstringStart === 0) {
type = 'startsWith';
} else {
const lastCharOfPreviousBlock = value[longestSubstringStart - 1];
if (SPLIT_CHARACTERS.includes(lastCharOfPreviousBlock)) {
longestSubstring = lastCharOfPreviousBlock + longestSubstring;
longestSubstringStart = longestSubstringStart - 1;
}
type = isEnd ? 'endsWith' : 'contains';
}
}
currentSubstring = '';
};

for (const token of tokens) {
const tokenType = tokenIsDynamic(token) ? 'dynamic' : 'static';

if (blockCount === 0 || tokenType !== lastTokenType) {
blockCount += 1;
}

if (tokenType === 'dynamic' && lastTokenType === 'static') {
checkLongest();
}

if (tokenType === 'static') currentSubstring += value.substr(currentPosition, token.length);
currentPosition += token.length;

// Add back in the split-by character
const nextCharacter = value[currentPosition];
if (SPLIT_CHARACTERS.includes(nextCharacter)) {
if (tokenType === 'static') currentSubstring += nextCharacter;
currentPosition += 1;
}

lastTokenType = tokenType;
}

if (blockCount === 1) {
// Entire string was dynamic, so we can't match on any part of it
if (lastTokenType === 'dynamic') return null;

// Entire string was static, so we can match the whole thing
longestSubstring = value;
longestSubstringStart = 0;
type = 'equals';
} else if (lastTokenType === 'static') {
// Do final check for longest if last token type was static
checkLongest(true);
}

const selectorInfo = {
match: longestSubstring,
operator: matchOperators.get(type),
type,
startPosition: longestSubstringStart,
};

// if there are more than 2 non-word tokens, mark it as dynamic
if (tokens.length - words.length >= 2) return true;
console.debug('selector info for "%s": %j', value, selectorInfo);

// if less than half of the tokens are words, mark it as dynamic
return words.length / tokens.length <= threshold;
return selectorInfo;
};
64 changes: 62 additions & 2 deletions test/web/cues.test.ts
Expand Up @@ -115,7 +115,7 @@ describe('buildCuesForElement', () => {
"level": 1,
"penalty": 40,
"type": "tag",
"value": "input:nth-of-type(5)",
"value": "input:nth-of-type(4)",
},
]
`);
Expand All @@ -129,7 +129,7 @@ describe('buildCuesForElement', () => {
"level": 1,
"penalty": 40,
"type": "tag",
"value": "input:nth-of-type(6)",
"value": "input:nth-of-type(8)",
},
]
`);
Expand All @@ -154,6 +154,66 @@ describe('buildCuesForElement', () => {
]
`);
});

it('builds cues with attributes that have dynamic beginning', async () => {
const cues = await buildCuesForElement('#ern84j8g0');
expect(cues).toMatchInlineSnapshot(`
Array [
Object {
"level": 1,
"penalty": 10,
"type": "attribute",
"value": "[name$=\\"-check\\"]",
},
Object {
"level": 1,
"penalty": 40,
"type": "tag",
"value": "input:nth-of-type(6)",
},
]
`);
});

it('builds cues with attributes that have dynamic ending', async () => {
const cues = await buildCuesForElement('#v9eonirh894');
expect(cues).toMatchInlineSnapshot(`
Array [
Object {
"level": 1,
"penalty": 10,
"type": "attribute",
"value": "[name^=\\"input-\\"]",
},
Object {
"level": 1,
"penalty": 40,
"type": "tag",
"value": "input:nth-of-type(5)",
},
]
`);
});

it('builds cues with attributes that have dynamic beginning and ending', async () => {
const cues = await buildCuesForElement('#fdg8e9v4');
expect(cues).toMatchInlineSnapshot(`
Array [
Object {
"level": 1,
"penalty": 10,
"type": "attribute",
"value": "[name*=\\"-blue-\\"]",
},
Object {
"level": 1,
"penalty": 40,
"type": "tag",
"value": "input:nth-of-type(7)",
},
]
`);
});
});

describe('buildCueValueForTag', () => {
Expand Down

0 comments on commit 62f749c

Please sign in to comment.