Last active
July 16, 2020 13:57
-
-
Save tw3/e09066bf361ac30aa404b223846182e4 to your computer and use it in GitHub Desktop.
XPath Builder, example usage: https://stackblitz.com/edit/angular-ivy-yzddtr?file=src%2Fapp%2Fapp.component.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Example: | |
* | |
* const builder = new XPathBuilder(); | |
* const xpathStr = builder | |
* .addDescendant({ nodeName: 'carbon-dropdown' }) | |
* .addDescendant({ className: 'bx--label', innerText: 'AWS Region' }) | |
* .addAncestor({ nodeName: 'carbon-dropdown' }) | |
* .build(); | |
* | |
* OR | |
* | |
* const xpathStr = $xp() | |
* .addDescendant({ nodeName: 'carbon-dropdown' }) | |
* .addDescendant({ className: 'bx--label', innerText: 'AWS Region' }) | |
* .addAncestor({ nodeName: 'carbon-dropdown' }) | |
* .build(); | |
*/ | |
export const $xp = xpathBuilderFactory; | |
function xpathBuilderFactory(mode?: XPathStartMode): XPathBuilder { | |
return new XPathBuilder(mode); | |
} | |
export class XPathBuilder { | |
private readonly paths: XPathNodePath[]; | |
private instanceNum: number; | |
private readonly pathTypeToStr: Record<XPathNodePathType, string> = { | |
'descendant': '//', | |
'child': '/', | |
'preceding-sibling': '/preceding-sibling::', | |
'following-sibling': '/following-sibling::', | |
'ancestor': '/ancestor::' | |
}; | |
constructor( | |
public readonly mode?: XPathStartMode | |
) { | |
if (!this.mode) { | |
this.mode = 'relative'; | |
} | |
this.paths = []; | |
} | |
addDescendant(input: XPathNodeInput): XPathBuilder { | |
const q: XPathNodeQuery = this.coerceQuery(input); | |
const p: XPathNodePath = { ...q, pathType: 'descendant' }; | |
this.paths.push(p); | |
return this; | |
} | |
addChild(input: XPathNodeInput): XPathBuilder { | |
const q: XPathNodeQuery = this.coerceQuery(input); | |
const p: XPathNodePath = { ...q, pathType: 'child' }; | |
this.paths.push(p); | |
return this; | |
} | |
addPrecedingSibling(input: XPathNodeInput): XPathBuilder { | |
const q: XPathNodeQuery = this.coerceQuery(input); | |
const p: XPathNodePath = { ...q, pathType: 'preceding-sibling' }; | |
this.paths.push(p); | |
return this; | |
} | |
addFollowingSibling(input: XPathNodeInput): XPathBuilder { | |
const q: XPathNodeQuery = this.coerceQuery(input); | |
const p: XPathNodePath = { ...q, pathType: 'following-sibling' }; | |
this.paths.push(p); | |
return this; | |
} | |
addAncestor(input: XPathNodeInput): XPathBuilder { | |
const q: XPathNodeQuery = this.coerceQuery(input); | |
const p: XPathNodePath = { ...q, pathType: 'ancestor' }; | |
this.paths.push(p); | |
return this; | |
} | |
addInstanceNum(num: number): XPathBuilder { | |
this.instanceNum = num; | |
return this; | |
} | |
build(): string { | |
let pathStr = this.paths.reduce((previousStr: string, p: XPathNodePath) => { | |
return previousStr + this.buildPath(p); | |
}, ''); | |
if (this.mode === 'relative') { | |
pathStr = `.${pathStr}`; | |
} | |
if (isPositiveInteger(this.instanceNum)) { | |
pathStr = `(${pathStr})[${this.instanceNum}]`; | |
} | |
return pathStr; | |
} | |
private buildPath(p: XPathNodePath): string { | |
return [ | |
this.getPathTypeStr(p), // e.g. '//' | |
this.getXPathNodePath(p), // e.g. 'carbon-dropdown' | |
this.getXPathPredicate(p) | |
].join(''); | |
} | |
private getPathTypeStr(p: XPathNodePath): string { | |
return this.pathTypeToStr[p.pathType]; | |
} | |
private getXPathNodePath(p: XPathNodePath): string { | |
return p.nodeName ? p.nodeName : '*'; | |
} | |
// e.g. [contains(concat(' ',@class,' '), ' bx--label ') and normalize-space(text()) = 'First'] | |
private getXPathPredicate(p: XPathNodePath): string { | |
const predicateArr: string[] = []; | |
if (p.className) { | |
predicateArr.push(`contains(concat(' ',@class,' '), ' ${p.className} ')`); | |
} | |
if (p.name) { | |
predicateArr.push(`@name='${p.name}'`); | |
} | |
if (p.innerText) { | |
predicateArr.push(`normalize-space(.) = '${p.innerText}'`); | |
// TODO: Consider normalize-space(.) instead | |
} | |
if (Array.isArray(p.predicates)) { | |
for (const pr of p.predicates) { | |
const str: string = this.getCustomXPathPredicate(pr); | |
if (str) { | |
predicateArr.push(str); | |
} | |
} | |
} | |
if (predicateArr.length === 0) { | |
return ''; | |
} | |
const joinedStr = `${predicateArr.join(' and ')}`; | |
return `[${joinedStr}]`; | |
} | |
private getCustomXPathPredicate(pr: XPathNodeAttributePredicate): string | undefined { | |
if (pr.attr && pr.endsWith) { | |
return `substring(@${pr.attr}, string-length(@${pr.attr}) -string-length('${pr.endsWith}') +1) = '${pr.endsWith}'`; | |
} | |
return undefined; | |
} | |
private coerceQuery(input: XPathNodeInput): XPathNodeQuery { | |
if (typeof input === 'string') { | |
return { nodeName: input } as XPathNodeQuery; | |
} | |
return input as XPathNodeQuery; | |
} | |
} | |
export type XPathNodeInput = XPathNodeQuery | string; | |
export interface XPathNodeQuery { | |
nodeName?: string; | |
className?: string; | |
name?: string; | |
innerText?: string; | |
predicates?: XPathNodePredicate[]; | |
} | |
export type XPathNodePredicate = XPathNodeAttributePredicate; | |
export interface XPathNodeAttributePredicate { | |
attr: string; | |
endsWith?: string; | |
} | |
export type XPathStartMode = 'absolute' | 'relative'; | |
type XPathNodePathType = 'descendant' | 'child' | 'preceding-sibling' | 'following-sibling' | 'ancestor'; | |
interface XPathNodePath extends XPathNodeQuery { | |
pathType: XPathNodePathType; | |
} | |
function isPositiveInteger(num): boolean { | |
return (isInteger(num) && num > 0); | |
} | |
export function isInteger(num): boolean { | |
return (parseInt(num) === num); | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment