Commit 1cc4e1ca authored by Kyle Keating's avatar Kyle Keating Committed by Kyle Keating

Add nodeFromXPath to range-js and refactor tests

- Refactor `range-js-test` and remove specific testing of internal functions such as `findChild` and `nodeFromXPathFallback`.

- `nodeFromXPath` ported code does not contain any code path for XML handling as the coffeescript previously did. This code was however previously untested and difficult to reproduce in practice. It was likely was never or rarely used and is over 6 years old.
parent 6e39b1c9
...@@ -20,9 +20,9 @@ export function xpathFromNode(el, relativeRoot) { ...@@ -20,9 +20,9 @@ export function xpathFromNode(el, relativeRoot) {
} }
/** /**
* Finds an element node using an xpath relative to the document root. * Finds an element node using an XPath relative to the document root.
*/ */
export function nodeFromXPath(xpath, root) { function nodeFromXPathFallback(xpath, root) {
const steps = xpath.substring(1).split('/'); const steps = xpath.substring(1).split('/');
let node = root; let node = root;
steps.forEach(step => { steps.forEach(step => {
...@@ -32,3 +32,38 @@ export function nodeFromXPath(xpath, root) { ...@@ -32,3 +32,38 @@ export function nodeFromXPath(xpath, root) {
}); });
return node; return node;
} }
/**
* Finds an element node using an XPath relative to the document root.
*
* Example:
* node = nodeFromXPath('/html/body/div/p[2]')
*/
export function nodeFromXPath(xpath, root = document) {
/**
* Attempt to evaluate a provided XPath. If the evaluation fails, then fall back to a
* manual evaluation using `nodeFromXPathFallback` that can evaluate very simple XPaths such
* as those generated by `xpathFromNode`
*/
function evaluateXPath(xp, root, nsResolver = null) {
try {
return document.evaluate(
'.' + xp,
root,
nsResolver,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue;
} catch (e) {
// In the case of an XPath error, fall back to a manual evaluation
// that should be sufficient for simple expressions.
//
// See http://www.w3.org/TR/DOM-Level-3-XPath/xpath.html#XPathException
//
// In practice, it is unknown whether this exception occurs often. This may
// be a good place to insert analytics.
return nodeFromXPathFallback(xp, root);
}
}
return evaluateXPath(xpath, root);
}
import $ from 'jquery'; import $ from 'jquery';
import { xpathFromNode, nodeFromXPath, $imports } from '../range-js'; import { nodeFromXPath, xpathFromNode, $imports } from '../range-js';
describe('annotator/anchoring/range-js', () => { describe('annotator/anchoring/range-js', () => {
describe('xpathFromNode', () => { describe('xpathFromNode', () => {
...@@ -24,6 +24,7 @@ describe('annotator/anchoring/range-js', () => { ...@@ -24,6 +24,7 @@ describe('annotator/anchoring/range-js', () => {
afterEach(() => { afterEach(() => {
container.remove(); container.remove();
$imports.$restore();
}); });
it('calls `simpleXPathJQuery`', () => { it('calls `simpleXPathJQuery`', () => {
...@@ -51,82 +52,61 @@ describe('annotator/anchoring/range-js', () => { ...@@ -51,82 +52,61 @@ describe('annotator/anchoring/range-js', () => {
describe('nodeFromXPath', () => { describe('nodeFromXPath', () => {
let container; let container;
let fakeFindChild; const html = `
<h1 id="h1-1">text</h1>
<p id="p-1">text<br/><br/><a id="a-1">text</a></p>
<p id="p-2">text<br/><em id="em-1"><br/>text</em>text</p>
<span>
<ul>
<li id="li-1">text 1</li>
<li id="li-2">text 2</li>
<li id="li-3">text 3</li>
</ul>
</span>`;
beforeEach(() => { beforeEach(() => {
container = document.createElement('div'); container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container); document.body.appendChild(container);
fakeFindChild = sinon.stub().returns(document.body);
$imports.$mock({
'./xpath-util': {
findChild: fakeFindChild,
},
});
}); });
afterEach(() => { afterEach(() => {
container.remove(); container.remove();
}); });
it('returns the last node returned from `findChild`', () => {
const span = document.createElement('span');
container.appendChild(span);
fakeFindChild.onFirstCall().returns(container);
fakeFindChild.onSecondCall().returns(span);
assert.equal(nodeFromXPath('/div[1]/span[1]', document.body), span);
});
[ [
{ {
xpath: '/div[1]', xpath: '/h1[1]',
params: [ nodeName: 'H1',
{
name: 'div',
idx: 1,
},
],
},
{
xpath: '/div[1]/span[3]/p[1]',
params: [
{
name: 'div',
idx: 1,
}, },
{ {
name: 'span', xpath: '/p[1]/a[1]/text()[1]',
idx: 3, nodeName: '#text',
}, },
{ {
name: 'p', xpath: '/span[1]/ul[1]/li[2]',
idx: 1, nodeName: 'LI',
}, },
],
},
{
xpath: '/DIV[2]/TEXT()[3]/SPAN[1]',
params: [
{ {
name: 'div', xpath: '/SPAN[1]/UL[1]/LI[2]',
idx: 2, nodeName: 'LI',
}, },
{ {
name: 'text()', xpath: '/SPAN[1]/UL[1]/LI[2]/text()',
idx: 3, nodeName: '#text',
},
{
name: 'span',
idx: 1,
},
],
}, },
].forEach(test => { ].forEach(test => {
it('calls `findChild` with the following node names and indices', () => { it('it returns the node associated with the XPath', () => {
nodeFromXPath(test.xpath, document.body); const result = nodeFromXPath(test.xpath, container);
test.params.forEach(call => { assert.equal(result.nodeName, test.nodeName);
assert.calledWith(fakeFindChild, document.body, call.name, call.idx);
}); });
it('it returns the node associated with the XPath when `document.evaluate` throws an error', () => {
const result = nodeFromXPath(test.xpath, container);
sinon.stub(document, 'evaluate').throws(new Error());
const resultFallback = nodeFromXPath(test.xpath, container);
assert.equal(result, resultFallback);
document.evaluate.restore();
}); });
}); });
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment