Skip to content

Commit

Permalink
fix: optimizeCues O(n^2)
Browse files Browse the repository at this point in the history
  • Loading branch information
jperl committed Aug 12, 2020
1 parent e5f305a commit ac203bf
Show file tree
Hide file tree
Showing 14 changed files with 712 additions and 7,740 deletions.
34 changes: 6 additions & 28 deletions src/web/cues.ts
@@ -1,7 +1,6 @@
import { getAttribute } from './attribute';
import { getElementText } from './element';
import { isDynamic } from './isDynamic';
import { SelectorPart } from './types';

const DEFAULT_ATTRIBUTE_LIST =
'data-cy,data-e2e,data-qa,/^data-test.*/,/^qa-.*/';
Expand Down Expand Up @@ -274,33 +273,12 @@ export const buildCues = ({ attribute, isClick, target }: BuildCues): Cue[] => {
return cues;
};

export const buildSelectorParts = (cues: Cue[]): SelectorPart[] => {
const levels = [...new Set(cues.map((cue) => cue.level))];

// sort descending
levels.sort((a, b) => b - a);

const parts: SelectorPart[] = [];

levels.forEach((level) => {
const cuesForLevel = cues.filter((cue) => cue.level === level);

const textCues = cuesForLevel.filter((cue) => cue.type === 'text');
if (textCues.length) {
parts.push({ name: 'text', body: textCues[0].value });
return;
export const findNearestPreferredAttributeCue = (cues: Cue[]): Cue | null => {
return cues.reduce((foundCue, cue) => {
if (cue.penalty === 0 && (!foundCue || foundCue.level > cue.level)) {
return cue;
}

cuesForLevel.sort((a, b) => {
if (a.type === 'tag') return -1;
if (b.type === 'tag') return 1;
return 0;
});

const bodyValues = cuesForLevel.map((cue) => cue.value);

parts.push({ name: 'css', body: bodyValues.join('') });
});

return parts;
return foundCue;
}, null);
};
123 changes: 0 additions & 123 deletions src/web/iterateCues.ts

This file was deleted.

154 changes: 154 additions & 0 deletions src/web/optimizeCues.ts
@@ -0,0 +1,154 @@
import { Cue, findNearestPreferredAttributeCue } from './cues';
import { buildSelectorParts, isMatch } from './selectorParts';
import { SelectorPart } from './types';

type CueTypes = {
css: Cue[];
text: Cue[];
};

type RankedCues = {
cues: Cue[];
penalty: number;
selectorParts: SelectorPart[];
};

/**
* Build all permutations of the longest cue group.
* There are multiple permutations, since each level can have either css or text cues.
*/
export const buildLongestCueGroups = (cues: Cue[]) => {
const cuesByLevel = new Map<number, CueTypes>();

// group cues by level and type
cues.forEach((cue) => {
const cueTypes = cuesByLevel.has(cue.level)
? cuesByLevel.get(cue.level)
: { css: [], text: [] };

if (cue.type === 'text') {
cueTypes.text.push(cue);
} else {
cueTypes.css.push(cue);
}

cuesByLevel.set(cue.level, cueTypes);
});

let cueGroups: Cue[][] = [];

// levels descending
const levels = [...cuesByLevel.keys()].sort((a, b) => b - a);

// go through each level and append it to each cue group
// cue groups can only have one type per level
// so we create a new group per type
levels.forEach((level) => {
const cueLevel = cuesByLevel.get(level);

const cueGroupsWithLevel: Cue[][] = [];

cueGroups.forEach((cueGroup) => {
if (cueLevel.css.length) {
cueGroupsWithLevel.push([...cueGroup, ...cueLevel.css]);
}

if (cueLevel.text.length) {
cueGroupsWithLevel.push([...cueGroup, ...cueLevel.text]);
}
});

// if this is the first level create a group per type
if (!cueGroups.length) {
if (cueLevel.css.length) {
cueGroupsWithLevel.push([...cueLevel.css]);
}

if (cueLevel.text.length) {
cueGroupsWithLevel.push([...cueLevel.text]);
}
}

cueGroups = cueGroupsWithLevel;
});

return cueGroups;
};

// Remove cues as long as the selector still matches the target
export const minimizeCues = (
cues: Cue[],
target: HTMLElement,
): RankedCues | null => {
// Order by penalty descending
let minCues = [...cues].sort((a, b) => {
// first sort by penalty
if (a.penalty < b.penalty) return 1;
if (a.penalty > b.penalty) return -1;

// prefer shorter values for the same penalty
if (a.value.length < b.value.length) return 1;
if (a.value.length > b.value.length) return -1;

return 0;
});

let minSelectorParts = buildSelectorParts(cues);

if (!isMatch({ selectorParts: minSelectorParts, target })) {
// this should never happen
console.debug(
'qawolf: element did not match all selector parts',
minSelectorParts,
target,
);
return null;
}

// Keep the nearest attribute
const cueToKeep = findNearestPreferredAttributeCue(cues);

for (let i = 0; i < minCues.length; i++) {
if (minCues[i] === cueToKeep) continue;

const cuesWithoutI = [...minCues];
cuesWithoutI.splice(i, 1);

const partsWithoutCue = buildSelectorParts(cuesWithoutI);

if (isMatch({ selectorParts: partsWithoutCue, target })) {
minCues = cuesWithoutI;
minSelectorParts = partsWithoutCue;
i -= 1;
}

continue;
}

const penalty = minCues.reduce((a, b) => a + b.penalty, 0);

return {
cues: minCues,
penalty,
selectorParts: minSelectorParts,
};
};

export const optimizeCues = (
cues: Cue[],
target: HTMLElement,
): SelectorPart[] | null => {
const cueGroups = buildLongestCueGroups(cues);

// minimize all the cue groups
// pick the lowest penalty one
const rankedSelectorParts = cueGroups
.map((cueGroup) => minimizeCues(cueGroup, target))
.filter((a) => !!a)
// order by penalty ascending
.sort((a, b) => a.penalty - b.penalty);

return rankedSelectorParts.length
? rankedSelectorParts[0].selectorParts
: null;
};
3 changes: 2 additions & 1 deletion src/web/qawolf.ts
Expand Up @@ -14,5 +14,6 @@ export {
} from './element';
export { formatArgument, interceptConsoleLogs } from './interceptConsoleLogs';
export { PageEventCollector } from './PageEventCollector';
export { buildSelector, clearSelectorCache, isMatch, toSelector } from './selector';
export { buildSelector, clearSelectorCache, toSelector } from './selector';
export { isMatch } from './selectorParts';
export { getXpath, nodeToDoc } from './serialize';

0 comments on commit ac203bf

Please sign in to comment.