Merge branch 'dom-tests'
diff --git a/.mocharc.js b/.mocharc.js
index a6239f7..8058a94 100644
--- a/.mocharc.js
+++ b/.mocharc.js
@@ -19,5 +19,5 @@
  */
 
 module.exports = {
-  require: ['./babel-register.js'],
+  require: ['./babel-register.js', 'global-jsdom/lib/register'],
 };
diff --git a/@types/dom-node-iterator/index.d.ts b/@types/dom-node-iterator/index.d.ts
deleted file mode 100644
index 0e10887..0000000
--- a/@types/dom-node-iterator/index.d.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-declare module 'dom-node-iterator' {
-  let createNodeIterator: Document['createNodeIterator'];
-  export default createNodeIterator;
-}
diff --git a/@types/dom-seek/index.d.ts b/@types/dom-seek/index.d.ts
index 0ba0753..5bc1bc2 100644
--- a/@types/dom-seek/index.d.ts
+++ b/@types/dom-seek/index.d.ts
@@ -1,3 +1,3 @@
 declare module 'dom-seek' {
-  export default function seek(iter: NodeIterator, where: number | Node): number;
+  export default function seek(iter: NodeIterator, where: number | Text): number;
 }
diff --git a/package.json b/package.json
index db5ac9c..190cae1 100644
--- a/package.json
+++ b/package.json
@@ -25,9 +25,9 @@
     "prepare": "lerna run prepare",
     "prepublishOnly": "yarn run build",
     "start": "yarn run web:server",
-    "test": "cross-env BABEL_ENV=test nyc mocha packages/*/test/**/*.[jt]s",
+    "test": "cross-env BABEL_ENV=test nyc mocha packages/*/test/**/*.test.[jt]s",
     "typecheck": "tsc && tsc -p tsconfig.tests.json",
-    "validate": "cross-env BABEL_ENV=test mocha test/**/*.[jt]s",
+    "validate": "cross-env BABEL_ENV=test mocha test/**/*.test.[jt]s",
     "web:build": "webpack  --config=web/webpack.config.js --mode development",
     "web:server": "webpack-dev-server --config=web/webpack.config.js --hot --mode development"
   },
@@ -58,7 +58,9 @@
     "eslint-plugin-node": "^11.0.0",
     "eslint-plugin-prettier": "^3.1.2",
     "file-loader": "^6.0.0",
+    "global-jsdom": "^6.1.0",
     "husky": "^4.2.1",
+    "jsdom": "^16.2.2",
     "lerna": "^3.20.2",
     "lint-staged": "^10.0.2",
     "mocha": "^7.0.0",
diff --git a/packages/dom/package.json b/packages/dom/package.json
index fb1f5a6..4f0baed 100644
--- a/packages/dom/package.json
+++ b/packages/dom/package.json
@@ -15,8 +15,7 @@
     "@babel/runtime-corejs3": "^7.8.7",
     "cartesian": "^1.0.1",
     "core-js": "^3.6.4",
-    "dom-node-iterator": "^3.5.3",
-    "dom-seek": "^4.0.3"
+    "dom-seek": "^5.1.0"
   },
   "engines": {
     "node": ">=10.0.0"
diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts
index 5b89ef6..6ef53d7 100644
--- a/packages/dom/src/index.ts
+++ b/packages/dom/src/index.ts
@@ -19,6 +19,6 @@
  */
 
 export * from './css';
-export * from './range';
+export * from './range/index';
 export * from './text-quote/index';
 export * from './highlight-range';
diff --git a/packages/dom/src/cartesian.ts b/packages/dom/src/range/cartesian.ts
similarity index 100%
rename from packages/dom/src/cartesian.ts
rename to packages/dom/src/range/cartesian.ts
diff --git a/packages/dom/test/index.js b/packages/dom/src/range/index.ts
similarity index 96%
rename from packages/dom/test/index.js
rename to packages/dom/src/range/index.ts
index ebd8b07..011e994 100644
--- a/packages/dom/test/index.js
+++ b/packages/dom/src/range/index.ts
@@ -18,4 +18,4 @@
  * under the License.
  */
 
-export {};
+export * from './match';
diff --git a/packages/dom/src/range.ts b/packages/dom/src/range/match.ts
similarity index 90%
rename from packages/dom/src/range.ts
rename to packages/dom/src/range/match.ts
index e465386..e540d5c 100644
--- a/packages/dom/src/range.ts
+++ b/packages/dom/src/range/match.ts
@@ -18,10 +18,10 @@
  * under the License.
  */
 
-import { ownerDocument } from './scope';
+import { ownerDocument } from '../scope';
 import { product } from './cartesian';
-import { RangeSelector, Selector } from '../../selector/src/types';
-import { DomMatcher, DomScope } from './types';
+import { RangeSelector, Selector } from '../../../selector/src/types';
+import { DomMatcher, DomScope } from '../types';
 
 export function makeCreateRangeSelectorMatcher(
   createMatcher: <T extends Selector>(selector: T) => DomMatcher
diff --git a/packages/dom/src/text-quote/describe.ts b/packages/dom/src/text-quote/describe.ts
index ef7eb53..15749c9 100644
--- a/packages/dom/src/text-quote/describe.ts
+++ b/packages/dom/src/text-quote/describe.ts
@@ -18,136 +18,104 @@
  * under the License.
  */
 
-import createNodeIterator from 'dom-node-iterator';
 import seek from 'dom-seek';
 
 import { TextQuoteSelector } from '../../../selector/src';
 import { DomScope } from '../types';
 import { ownerDocument, rangeFromScope } from '../scope';
-import { createTextQuoteSelectorMatcher } from './match';
-
-function firstTextNodeInRange(range: Range): Text {
-  const { startContainer } = range;
-
-  if (isTextNode(startContainer)) return startContainer;
-
-  const root = range.commonAncestorContainer;
-  const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT);
-  return iter.nextNode() as Text;
-}
 
 export async function describeTextQuote(
   range: Range,
   scope: DomScope = ownerDocument(range).documentElement,
 ): Promise<TextQuoteSelector> {
+  range = range.cloneRange();
+
+  // Take the part of the range that falls within the scope.
+  const scopeAsRange = rangeFromScope(scope);
+  if (!scopeAsRange.isPointInRange(range.startContainer, range.startOffset))
+    range.setStart(scopeAsRange.startContainer, scopeAsRange.startOffset);
+  if (!scopeAsRange.isPointInRange(range.endContainer, range.endOffset))
+    range.setEnd(scopeAsRange.endContainer, scopeAsRange.endOffset);
+
   const exact = range.toString();
 
   const result: TextQuoteSelector = { type: 'TextQuoteSelector', exact };
 
-  const { prefix, suffix } = await calculateContextForDisambiguation(range, result, scope);
+  const { prefix, suffix } = calculateContextForDisambiguation(range, result, scope);
   result.prefix = prefix;
   result.suffix = suffix;
 
   return result
 }
 
-async function calculateContextForDisambiguation(
+function calculateContextForDisambiguation(
   range: Range,
   selector: TextQuoteSelector,
   scope: DomScope,
-): Promise<{ prefix?: string, suffix?: string }> {
-  const scopeAsRange = rangeFromScope(scope);
-  const root = scopeAsRange.commonAncestorContainer;
-  const text = scopeAsRange.toString();
+): { prefix?: string, suffix?: string } {
+  const exactText = selector.exact;
+  const scopeText = rangeFromScope(scope).toString();
+  const targetStartIndex = getRangeTextPosition(range, scope);
+  const targetEndIndex = targetStartIndex + exactText.length;
 
-  const matcher = createTextQuoteSelectorMatcher(selector);
+  // Find all matches of the text in the scope.
+  const stringMatches: number[] = [];
+  let fromIndex = 0;
+  while (fromIndex <= scopeText.length) {
+    const matchIndex = scopeText.indexOf(exactText, fromIndex);
+    if (matchIndex === -1) break;
+    stringMatches.push(matchIndex);
+    fromIndex = matchIndex + 1;
+  }
 
-  const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT);
-
-  const startNode = firstTextNodeInRange(range);
-  const startIndex =
-    isTextNode(range.startContainer)
-      ? seek(iter, startNode) + range.startOffset
-      : seek(iter, startNode);
-  const endIndex = startIndex + selector.exact.length;
-
+  // Count for each undesired match the required prefix and suffix lengths, such that either of them
+  // would have invalidated the match.
   const affixLengthPairs: Array<[number, number]> = [];
+  for (const matchStartIndex of stringMatches) {
+    const matchEndIndex = matchStartIndex + exactText.length
 
-  for await (const match of matcher(scopeAsRange)) {
-    const matchIter = createNodeIterator(root, NodeFilter.SHOW_TEXT);
-
-    const matchStartNode = firstTextNodeInRange(match);
-    const matchStartIndex =
-      isTextNode(match.startContainer)
-        ? seek(matchIter, matchStartNode) + match.startOffset
-        : seek(matchIter, matchStartNode);
-    const matchEndIndex = matchStartIndex + match.toString().length;
-
-    // If the match is the same as the input range, continue.
-    if (matchStartIndex === startIndex || matchEndIndex === endIndex) {
+    // Skip the found match if it is the actual target.
+    if (matchStartIndex === targetStartIndex)
       continue;
-    }
 
-    // Determine how many prefix characters are shared.
-    const prefixLength = overlapRight(
-      text.substring(0, matchStartIndex),
-      text.substring(0, startIndex),
+    // Count how many characters before & after them the false match and target have in common.
+    const sufficientPrefixLength = charactersNeededToBeUnique(
+      scopeText.substring(0, targetStartIndex),
+      scopeText.substring(0, matchStartIndex),
+      true,
     );
-
-    // Determine how many suffix characters are shared.
-    const suffixLength = overlap(
-      text.substring(matchEndIndex),
-      text.substring(endIndex),
+    const sufficientSuffixLength = charactersNeededToBeUnique(
+      scopeText.substring(targetEndIndex),
+      scopeText.substring(matchEndIndex),
+      false,
     );
-
-    // Record the affix lengths that would have precluded this match.
-    affixLengthPairs.push([prefixLength + 1, suffixLength + 1]);
+    affixLengthPairs.push([sufficientPrefixLength, sufficientSuffixLength]);
   }
 
-  // Construct and return an unambiguous selector.
-  let prefix, suffix;
-  if (affixLengthPairs.length) {
-    const [prefixLength, suffixLength] = minimalSolution(affixLengthPairs);
-
-    if (prefixLength > 0 && startIndex > 0) {
-      prefix = text.substring(startIndex - prefixLength, startIndex);
-    }
-
-    if (suffixLength > 0 && endIndex < text.length) {
-      suffix = text.substring(endIndex, endIndex + suffixLength);
-    }
-  }
-
+  // Find the prefix and suffix that would invalidate all mismatches, using the minimal characters
+  // for prefix and suffix combined.
+  const [prefixLength, suffixLength] = minimalSolution(affixLengthPairs);
+  const prefix = scopeText.substring(targetStartIndex - prefixLength, targetStartIndex);
+  const suffix = scopeText.substring(targetEndIndex, targetEndIndex + suffixLength);
   return { prefix, suffix };
 }
 
-function overlap(text1: string, text2: string) {
-  let count = 0;
-
-  while (count < text1.length && count < text2.length) {
-    const c1 = text1[count];
-    const c2 = text2[count];
-    if (c1 !== c2) break;
-    count++;
-  }
-
-  return count;
-}
-
-function overlapRight(text1: string, text2: string) {
-  let count = 0;
-
-  while (count < text1.length && count < text2.length) {
-    const c1 = text1[text1.length - 1 - count];
-    const c2 = text2[text2.length - 1 - count];
-    if (c1 !== c2) break;
-    count++;
-  }
-
-  return count;
+function charactersNeededToBeUnique(target: string, impostor: string, reverse: boolean = false) {
+  // Count how many characters the two strings have in common.
+  let overlap = 0;
+  const charAt = (s: string, i: number) => reverse ? s[s.length - 1 - i] : s[overlap];
+  while (overlap < target.length && charAt(target, overlap) === charAt(impostor, overlap))
+    overlap++;
+  if (overlap === target.length)
+    return Infinity; // (no substring of target can make it distinguishable from its impostor)
+  else
+    return overlap + 1;
 }
 
 function minimalSolution(requirements: Array<[number, number]>): [number, number] {
+  // Ensure we try solutions with an empty prefix or suffix.
+  requirements.push([0, 0]);
+
   // Build all the pairs and order them by their sums.
   const pairs = requirements.flatMap(l => requirements.map<[number, number]>(r => [l[0], r[1]]));
   pairs.sort((a, b) => a[0] + a[1] - (b[0] + b[1]));
@@ -164,6 +132,47 @@
   return pairs[pairs.length - 1];
 }
 
+// Get the index of the first character of range within the text of scope.
+function getRangeTextPosition(range: Range, scope: DomScope): number {
+  const scopeAsRange = rangeFromScope(scope);
+  const iter = document.createNodeIterator(
+    scopeAsRange.commonAncestorContainer,
+    NodeFilter.SHOW_TEXT,
+    {
+      acceptNode(node: Text) {
+        // Only reveal nodes within the range
+        return scopeAsRange.intersectsNode(node)
+          ? NodeFilter.FILTER_ACCEPT
+          : NodeFilter.FILTER_REJECT
+      },
+    },
+  );
+  const scopeOffset = isTextNode(scopeAsRange.startContainer) ? scopeAsRange.startOffset : 0;
+  if (isTextNode(range.startContainer))
+    return seek(iter, range.startContainer) + range.startOffset - scopeOffset;
+  else
+    return seek(iter, firstTextNodeInRange(range)) - scopeOffset;
+}
+
+function firstTextNodeInRange(range: Range): Text {
+  // Find the first text node inside the range.
+  const iter = document.createNodeIterator(
+    range.commonAncestorContainer,
+    NodeFilter.SHOW_TEXT,
+    {
+      acceptNode(node: Text) {
+        // Only reveal nodes within the range; and skip any empty text nodes.
+        return range.intersectsNode(node) && node.length > 0
+          ? NodeFilter.FILTER_ACCEPT
+          : NodeFilter.FILTER_REJECT
+      },
+    },
+  );
+  const node = iter.nextNode() as Text | null;
+  if (node === null) throw new Error('Range contains no text nodes');
+  return node;
+}
+
 function isTextNode(node: Node): node is Text {
-  return node.nodeType === Node.TEXT_NODE
+  return node.nodeType === Node.TEXT_NODE;
 }
diff --git a/packages/dom/src/text-quote/index.ts b/packages/dom/src/text-quote/index.ts
index 9f77e75..bb73732 100644
--- a/packages/dom/src/text-quote/index.ts
+++ b/packages/dom/src/text-quote/index.ts
@@ -18,5 +18,5 @@
  * under the License.
  */
 
- export * from './describe';
+export * from './describe';
 export * from './match';
diff --git a/packages/dom/src/text-quote/match.ts b/packages/dom/src/text-quote/match.ts
index 2657a6f..18b077e 100644
--- a/packages/dom/src/text-quote/match.ts
+++ b/packages/dom/src/text-quote/match.ts
@@ -18,7 +18,6 @@
  * under the License.
  */
 
-import createNodeIterator from 'dom-node-iterator';
 import seek from 'dom-seek';
 
 import { TextQuoteSelector } from '../../../selector/src';
@@ -28,86 +27,62 @@
 export function createTextQuoteSelectorMatcher(selector: TextQuoteSelector): DomMatcher {
   return async function* matchAll(scope: DomScope) {
     const document = ownerDocument(scope);
-    const range = rangeFromScope(scope);
-    const root = range.commonAncestorContainer;
-    const text = range.toString();
+    const scopeAsRange = rangeFromScope(scope);
+    const scopeText = scopeAsRange.toString();
 
     const exact = selector.exact;
     const prefix = selector.prefix || '';
     const suffix = selector.suffix || '';
-    const pattern = prefix + exact + suffix;
+    const searchPattern = prefix + exact + suffix;
 
-    const iter = createNodeIterator(root, NodeFilter.SHOW_TEXT);
+    const iter = document.createNodeIterator(
+      scopeAsRange.commonAncestorContainer,
+      NodeFilter.SHOW_TEXT,
+      {
+        acceptNode(node: Text) {
+          // Only reveal nodes within the range; and skip any empty text nodes.
+          return scopeAsRange.intersectsNode(node) && node.length > 0
+            ? NodeFilter.FILTER_ACCEPT
+            : NodeFilter.FILTER_REJECT
+        },
+      },
+    );
+
+    // The index of the first character of iter.referenceNode inside the text.
+    let referenceNodeIndex = isTextNode(scopeAsRange.startContainer)
+      ? -scopeAsRange.startOffset
+      : 0;
 
     let fromIndex = 0;
-    let referenceNodeIndex = 0;
-
-    if (isTextNode(range.startContainer)) {
-      referenceNodeIndex -= range.startOffset;
-    }
-
-    while (fromIndex < text.length) {
-      const patternStartIndex = text.indexOf(pattern, fromIndex);
+    while (fromIndex <= scopeText.length) {
+      // Find the quote with its prefix and suffix in the string.
+      const patternStartIndex = scopeText.indexOf(searchPattern, fromIndex);
       if (patternStartIndex === -1) return;
 
-      const match = document.createRange();
-
+      // Correct for the prefix and suffix lengths.
       const matchStartIndex = patternStartIndex + prefix.length;
       const matchEndIndex = matchStartIndex + exact.length;
 
-      // Seek to the start of the match.
+      // Create a range to represent this exact quote in the dom.
+      const match = document.createRange();
+
+      // Seek to the start of the match, make the range start there.
       referenceNodeIndex += seek(iter, matchStartIndex - referenceNodeIndex);
-
-      // Normalize the reference to the start of the match.
-      if (!iter.pointerBeforeReferenceNode) {
-        // Peek forward and skip over any empty nodes.
-        if (iter.nextNode()) {
-          while ((iter.referenceNode.nodeValue as String).length === 0) {
-            iter.nextNode();
-          }
-
-          // The iterator now points to the end of the reference node.
-          // Move the iterator back to the start of the reference node.
-          iter.previousNode();
-        }
-      }
-
-      // Record the start container and offset.
       match.setStart(iter.referenceNode, matchStartIndex - referenceNodeIndex);
 
-      // Seek to the end of the match.
+      // Seek to the end of the match, make the range end there.
       referenceNodeIndex += seek(iter, matchEndIndex - referenceNodeIndex);
-
-      // Normalize the reference to the end of the match.
-      if (!iter.pointerBeforeReferenceNode) {
-        // Peek forward and skip over any empty nodes.
-        if (iter.nextNode()) {
-          while ((iter.referenceNode.nodeValue as String).length === 0) {
-            iter.nextNode();
-          }
-
-          // The iterator now points to the end of the reference node.
-          // Move the iterator back to the start of the reference node.
-          iter.previousNode();
-        }
-
-        // Maybe seek backwards to the start of the node.
-        referenceNodeIndex += seek(iter, iter.referenceNode);
-      }
-
-      // Record the end container and offset.
       match.setEnd(iter.referenceNode, matchEndIndex - referenceNodeIndex);
 
       // Yield the match.
       yield match;
 
-      // Advance the search forward.
+      // Advance the search forward to detect multiple occurrences.
       fromIndex = matchStartIndex + 1;
-      referenceNodeIndex += seek(iter, fromIndex - referenceNodeIndex);
     }
   };
 }
 
 function isTextNode(node: Node): node is Text {
-  return node.nodeType === Node.TEXT_NODE
+  return node.nodeType === Node.TEXT_NODE;
 }
diff --git a/packages/dom/test/cartesian.ts b/packages/dom/test/range/cartesian.test.ts
similarity index 96%
rename from packages/dom/test/cartesian.ts
rename to packages/dom/test/range/cartesian.test.ts
index 9ff47eb..5fd854b 100644
--- a/packages/dom/test/cartesian.ts
+++ b/packages/dom/test/range/cartesian.test.ts
@@ -19,7 +19,7 @@
  */
 
 import { assert } from 'chai';
-import { product } from '../src/cartesian';
+import { product } from '../../src/range/cartesian';
 
 async function* gen1() {
   yield 1;
diff --git a/packages/dom/test/text-quote/describe-cases.ts b/packages/dom/test/text-quote/describe-cases.ts
new file mode 100644
index 0000000..129804d
--- /dev/null
+++ b/packages/dom/test/text-quote/describe-cases.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TextQuoteSelector } from "../../../selector/src";
+import { RangeInfo } from "./utils";
+
+const testCases: {
+  [name: string]: {
+    html: string,
+    range: RangeInfo,
+    expected: TextQuoteSelector,
+  }
+} = {
+  'simple': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    range: {
+      startContainerXPath: '//b/text()',
+      startOffset: 12,
+      endContainerXPath: '//b/text()',
+      endOffset: 20,
+    },
+    expected: {
+      type: 'TextQuoteSelector',
+      exact: 'dolor am',
+      prefix: '',
+      suffix: '',
+    },
+  },
+  'minimal prefix': {
+    html: '<b>To annotate or not to annotate.</b>',
+    range: {
+      startContainerXPath: '//b/text()',
+      startOffset: 22,
+      endContainerXPath: '//b/text()',
+      endOffset: 26,
+    },
+    expected: {
+      type: 'TextQuoteSelector',
+      exact: 'anno',
+      prefix: 'to ',
+      suffix: '',
+    },
+  },
+  'minimal suffix': {
+    html: '<b>To annotate or not to annotate.</b>',
+    range: {
+      startContainerXPath: '//b/text()',
+      startOffset: 7,
+      endContainerXPath: '//b/text()',
+      endOffset: 11,
+    },
+    expected: {
+      type: 'TextQuoteSelector',
+      exact: 'tate',
+      prefix: '',
+      suffix: ' ',
+    },
+  },
+  'use suffix for start of text': {
+    html: '<b>to annotate or not to annotate.</b>',
+    range: {
+      startContainerXPath: '//b/text()',
+      startOffset: 0,
+      endContainerXPath: '//b/text()',
+      endOffset: 2,
+    },
+    expected: {
+      type: 'TextQuoteSelector',
+      exact: 'to',
+      prefix: '',
+      suffix: ' annotate ',
+    },
+  },
+  'use prefix for end of text': {
+    html: '<b>To annotate or not to annotate</b>',
+    range: {
+      startContainerXPath: '//b/text()',
+      startOffset: 26,
+      endContainerXPath: '//b/text()',
+      endOffset: 30,
+    },
+    expected: {
+      type: 'TextQuoteSelector',
+      exact: 'tate',
+      prefix: 'to anno',
+      suffix: '',
+    },
+  },
+  'empty quote': {
+    html: '<b>To annotate or not to annotate</b>',
+    range: {
+      startContainerXPath: '//b/text()',
+      startOffset: 11,
+      endContainerXPath: '//b/text()',
+      endOffset: 11,
+    },
+    expected: {
+      type: 'TextQuoteSelector',
+      exact: '',
+      prefix: 'e',
+      suffix: ' ',
+    },
+  },
+  'across elements': {
+    html: '<b>To annotate or <i>not</i> to <u>anno</u>tate</b>',
+    range: {
+      startContainerXPath: '//u/text()',
+      startOffset: 0,
+      endContainerXPath: '//b/text()[3]',
+      endOffset: 2,
+    },
+    expected: {
+      type: 'TextQuoteSelector',
+      exact: 'annota',
+      prefix: 'to ',
+      suffix: '',
+    },
+  },
+};
+
+export default testCases;
diff --git a/packages/dom/test/text-quote/describe.test.ts b/packages/dom/test/text-quote/describe.test.ts
new file mode 100644
index 0000000..f962157
--- /dev/null
+++ b/packages/dom/test/text-quote/describe.test.ts
@@ -0,0 +1,95 @@
+/**
+ * @license
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { assert } from 'chai';
+import { describeTextQuote } from '../../src/text-quote/describe';
+import testCases from './describe-cases';
+import testMatchCases from './match-cases';
+import { hydrateRange, evaluateXPath } from './utils';
+
+const domParser = new window.DOMParser();
+
+describe('describeTextQuote', () => {
+  for (const [name, { html, range, expected }] of Object.entries(testCases)) {
+    it(`works for case: ${name}`, async () => {
+      const doc = domParser.parseFromString(html, 'text/html');
+      const result = await describeTextQuote(hydrateRange(range, doc), doc);
+      assert.deepEqual(result, expected);
+    })
+  }
+
+  it('works with custom scope', async () => {
+    const { html, range } = testCases['minimal prefix'];
+    const doc = domParser.parseFromString(html, 'text/html');
+    const scope = document.createRange();
+    scope.setStart(evaluateXPath(doc, '//b/text()'), 15);
+    scope.setEnd(evaluateXPath(doc, '//b/text()'), 30); // "not to annotate"
+    const result = await describeTextQuote(hydrateRange(range, doc), scope);
+    assert.deepEqual(result, {
+      type: 'TextQuoteSelector',
+      exact: 'anno',
+      prefix: '', // no prefix needed in this scope.
+      suffix: '',
+    });
+  });
+
+  it('strips part of the range outside the scope', async () => {
+    const { html, range } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+    const scope = document.createRange();
+    scope.setStart(evaluateXPath(doc, '//b/text()'), 6);
+    scope.setEnd(evaluateXPath(doc, '//b/text()'), 17); // "ipsum dolor"
+    const result = await describeTextQuote(hydrateRange(range, doc), scope);
+    assert.deepEqual(result, {
+      type: 'TextQuoteSelector',
+      exact: 'dolor',
+      prefix: '',
+      suffix: '',
+    });
+  });
+
+  it('works if the range equals the scope', async () => {
+    const { html, range, expected } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+    const result = await describeTextQuote(hydrateRange(range, doc), hydrateRange(range, doc));
+    assert.deepEqual(result, expected);
+  });
+
+  describe('inverts test cases of text quote matcher', () => {
+    const applicableTestCases = Object.entries(testMatchCases)
+      .filter(([_, { expected }]) => expected.length > 0);
+
+    for (const [name, { html, selector, expected }] of applicableTestCases) {
+      it(`case: '${name}'`, async () => {
+        const doc = domParser.parseFromString(html, 'text/html');
+        for (const rangeInfo of expected) {
+          const range = hydrateRange(rangeInfo, doc);
+          const result = await describeTextQuote(range, doc);
+          assert.equal(result.exact, selector.exact);
+          // Our result may have a different combination of prefix/suffix; only check for obvious inconsistency.
+          if (selector.prefix && result.prefix)
+            assert(selector.prefix.endsWith(result.prefix.substring(result.prefix.length - selector.prefix.length)), 'Inconsistent prefixes');
+          if (selector.suffix && result.suffix)
+            assert(selector.suffix.startsWith(result.suffix.substring(0, selector.suffix.length)), 'Inconsistent suffixes');
+        }
+      });
+    }
+  });
+});
diff --git a/packages/dom/test/text-quote/match-cases.ts b/packages/dom/test/text-quote/match-cases.ts
new file mode 100644
index 0000000..1748d1d
--- /dev/null
+++ b/packages/dom/test/text-quote/match-cases.ts
@@ -0,0 +1,355 @@
+/**
+ * @license
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TextQuoteSelector } from "../../../selector/src";
+import { RangeInfo } from "./utils";
+
+const testCases: {
+  [name: string]: {
+    html: string,
+    selector: TextQuoteSelector,
+    expected: RangeInfo[],
+  }
+} = {
+  'simple': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'dolor am',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 12,
+        endContainerXPath: '//b/text()',
+        endOffset: 20,
+      },
+    ],
+  },
+  'first characters': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'lorem ipsum',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 0,
+        endContainerXPath: '//b/text()',
+        endOffset: 11,
+      },
+    ],
+  },
+  'last characters': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'yada yada',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 23,
+        endContainerXPath: '//b/text()',
+        endOffset: 32,
+      },
+    ],
+  },
+  'across elements': {
+    html: '<b>lorem <i>ipsum</i> dolor <u>amet</u> yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'dolor am',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()[2]',
+        startOffset: 1,
+        endContainerXPath: '//u/text()',
+        endOffset: 2,
+      },
+    ],
+  },
+  'exact element contents': {
+    html: '<b>lorem <i>ipsum dolor</i> amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'ipsum dolor',
+    },
+    expected: [
+      {
+        startContainerXPath: '//i/text()',
+        startOffset: 0,
+        endContainerXPath: '//b/text()[2]',
+        endOffset: 0,
+      },
+    ],
+  },
+  'text inside <head>': {
+    html: '<head><title>The title</title></head><b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'title',
+    },
+    expected: [
+      {
+        startContainerXPath: '//title/text()',
+        startOffset: 4,
+        endContainerXPath: '//b/text()[1]',
+        endOffset: 0,
+      },
+    ],
+  },
+  'two matches': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'yada',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 23,
+        endContainerXPath: '//b/text()',
+        endOffset: 27,
+      },
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 28,
+        endContainerXPath: '//b/text()',
+        endOffset: 32,
+      },
+    ],
+  },
+  'overlapping matches': {
+    html: '<b>bananas</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'ana',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 1,
+        endContainerXPath: '//b/text()',
+        endOffset: 4,
+      },
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 3,
+        endContainerXPath: '//b/text()',
+        endOffset: 6,
+      },
+    ],
+  },
+  'no matches': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'holy grail',
+    },
+    expected: [],
+  },
+  'with prefix': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'yada',
+      prefix: 't ',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 23,
+        endContainerXPath: '//b/text()',
+        endOffset: 27,
+      },
+    ],
+  },
+  'with suffix': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'o',
+      suffix: 'l',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 13,
+        endContainerXPath: '//b/text()',
+        endOffset: 14,
+      },
+    ],
+  },
+  'with prefix and suffix': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'o',
+      prefix: 'l',
+      suffix: 're',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 1,
+        endContainerXPath: '//b/text()',
+        endOffset: 2,
+      },
+    ],
+  },
+  'with prefix and suffix, two matches': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'o',
+      prefix: 'l',
+      suffix: 'r',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 1,
+        endContainerXPath: '//b/text()',
+        endOffset: 2,
+      },
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 15,
+        endContainerXPath: '//b/text()',
+        endOffset: 16,
+      },
+    ],
+  },
+  'with prefix, no matches': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'dolor',
+      prefix: 'oopsum ',
+    },
+    expected: [],
+  },
+  'with suffix, no matches': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'dolor',
+      suffix: ' amot',
+    },
+    expected: [],
+  },
+  'with suffix, no matches due to whitespace': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'dolor',
+      suffix: 'a',
+    },
+    expected: [],
+  },
+  'with empty prefix and suffix': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'dolor am',
+      prefix: '',
+      suffix: '',
+    },
+    expected: [
+      {
+        startContainerXPath: '//b/text()',
+        startOffset: 12,
+        endContainerXPath: '//b/text()',
+        endOffset: 20,
+      },
+    ],
+  },
+  'empty quote': {
+    html: '<b>lorem</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: '',
+    },
+    // A five character string contains six spots to find an empty string
+    expected: Array(6).fill(null).map((_, i) => ({
+      startContainerXPath: '//b/text()',
+      startOffset: i,
+      endContainerXPath: '//b/text()',
+      endOffset: i,
+    }))
+  },
+  'empty quote, with prefix': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: '',
+      prefix: 'dolor',
+    },
+    expected: [{
+      startContainerXPath: '//b/text()',
+      startOffset: 17,
+      endContainerXPath: '//b/text()',
+      endOffset: 17,
+    }]
+  },
+  'empty quote, with suffix': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: '',
+      suffix: 'i',
+    },
+    expected: [{
+      startContainerXPath: '//b/text()',
+      startOffset: 6,
+      endContainerXPath: '//b/text()',
+      endOffset: 6,
+    }]
+  },
+  'empty quote, with prefix and suffix': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: '',
+      prefix: 'lorem ',
+      suffix: 'ipsum',
+    },
+    expected: [{
+      startContainerXPath: '//b/text()',
+      startOffset: 6,
+      endContainerXPath: '//b/text()',
+      endOffset: 6,
+    }]
+  },
+  'empty quote, no matches': {
+    html: '<b>lorem ipsum dolor amet yada yada</b>',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: '',
+      prefix: 'X',
+    },
+    expected: [],
+  }
+};
+
+export default testCases;
diff --git a/packages/dom/test/text-quote/match.test.ts b/packages/dom/test/text-quote/match.test.ts
new file mode 100644
index 0000000..3148a3f
--- /dev/null
+++ b/packages/dom/test/text-quote/match.test.ts
@@ -0,0 +1,214 @@
+/**
+ * @license
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { assert } from 'chai';
+import { createTextQuoteSelectorMatcher } from '../../src/text-quote/match';
+import { TextQuoteSelector } from '../../../selector/src/types';
+import { DomScope } from '../../src/types';
+import testCases from './match-cases';
+import { evaluateXPath, RangeInfo } from './utils';
+
+const domParser = new window.DOMParser();
+
+describe('createTextQuoteSelectorMatcher', () => {
+  for (const [name, { html, selector, expected }] of Object.entries(testCases)) {
+    it(`works for case: '${name}'`, async () => {
+      const doc = domParser.parseFromString(html, 'text/html');
+      await testMatcher(doc, doc, selector, expected);
+    });
+  }
+
+  it('handles adjacent text nodes', async () => {
+    const { html, selector } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+    const textNode = evaluateXPath(doc, '//b/text()') as Text;
+
+    for (let index = textNode.length - 1; index > 0; index--)
+      textNode.splitText(index);
+    // console.log([...textNode.parentNode.childNodes].map(node => node.textContent))
+    // → 'l',  'o', 'r', 'e', 'm', …
+
+    await testMatcher(doc, doc, selector, [
+      {
+        startContainerXPath: '//b/text()[13]',
+        startOffset: 0,
+        endContainerXPath: '//b/text()[21]',
+        endOffset: 0,
+      },
+    ]);
+  });
+
+  it('handles empty text nodes', async () => {
+    const { html, selector } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    const textNode = evaluateXPath(doc, '//b/text()') as Text;
+    textNode.splitText(textNode.length);
+    textNode.splitText(20);
+    textNode.splitText(20);
+    textNode.splitText(17);
+    textNode.splitText(17);
+    textNode.splitText(12);
+    textNode.splitText(12);
+    textNode.splitText(0);
+    // console.log([...textNode.parentNode.childNodes].map(node => node.textContent))
+    // → '', 'lorem ipsum ', '', 'dolor', '', ' am', '', 'et yada yada', ''
+
+    await testMatcher(doc, doc, selector, [
+      {
+        startContainerXPath: '//b/text()[4]', // "dolor"
+        startOffset: 0,
+        endContainerXPath: '//b/text()[8]', // "et yada yada"
+        endOffset: 0,
+      },
+    ]);
+  });
+
+  it('works with parent of text as scope', async () => {
+    const { html, selector, expected } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    await testMatcher(doc, evaluateXPath(doc, '//b'), selector, expected);
+  });
+
+  it('works with parent of text as scope, when matching its first characters', async () => {
+    const { html, selector, expected } = testCases['first characters'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    await testMatcher(doc, evaluateXPath(doc, '//b'), selector, expected);
+  });
+
+  it('works with parent of text as scope, when matching its first characters, with an empty text node', async () => {
+    const { html, selector } = testCases['first characters'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    const textNode = evaluateXPath(doc, '//b/text()') as Text;
+    textNode.splitText(0);
+
+    await testMatcher(doc, evaluateXPath(doc, '//b'), selector, [
+      {
+        startContainerXPath: '//b/text()[2]',
+        startOffset: 0,
+        endContainerXPath: '//b/text()[2]',
+        endOffset: 11,
+      },
+    ]);
+  });
+
+  it('works when scope is a Range within one text node', async () => {
+    const { html, selector, expected } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    // Use the substring ‘ipsum dolor amet’ as scope.
+    const scope = document.createRange();
+    scope.setStart(evaluateXPath(doc, '//b/text()'), 6);
+    scope.setEnd(evaluateXPath(doc, '//b/text()'), 22);
+    await testMatcher(doc, scope, selector, expected);
+  });
+
+  it('works when scope is a Range with both ends inside text nodes', async () => {
+    const { html, selector, expected } = testCases['across elements'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    // Use the substring ‘sum dolor am’ as scope.
+    const scope = document.createRange();
+    scope.setStart(evaluateXPath(doc, '//i/text()'), 2);
+    scope.setEnd(evaluateXPath(doc, '//u/text()'), 2);
+    await testMatcher(doc, scope, selector, expected);
+  });
+
+  it('works when scope is a Range with both ends inside elements', async () => {
+    const { html, selector, expected } = testCases['across elements'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    const scope = document.createRange();
+    scope.setStart(evaluateXPath(doc, '//b'), 1); // before the <i>
+    scope.setEnd(evaluateXPath(doc, '//b'), 4); // before the " yada yada"
+    await testMatcher(doc, scope, selector, expected);
+  });
+
+  it('ignores quote when scope is an empty range', async () => {
+    const { html, selector } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    const scope = document.createRange();
+    await testMatcher(doc, scope, selector, []);
+  });
+
+  it('ignores quote extending just beyond scope', async () => {
+    const { html, selector } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    const scope = document.createRange();
+    scope.setStart(evaluateXPath(doc, '//b/text()'), 0);
+    scope.setEnd(evaluateXPath(doc, '//b/text()'), 19);
+    await testMatcher(doc, scope, selector, []);
+  });
+
+  it('ignores quote starting just before scope', async () => {
+    const { html, selector } = testCases['simple'];
+    const doc = domParser.parseFromString(html, 'text/html');
+
+    const scope = document.createRange();
+    scope.setStart(evaluateXPath(doc, '//b/text()'), 13);
+    scope.setEnd(evaluateXPath(doc, '//b/text()'), 32);
+    await testMatcher(doc, scope, selector, []);
+  });
+});
+
+async function testMatcher(
+  doc: Document,
+  scope: DomScope,
+  selector: TextQuoteSelector,
+  expected: RangeInfo[],
+) {
+  const matcher = createTextQuoteSelectorMatcher(selector);
+  const matches = [];
+  for await (const value of matcher(scope))
+    matches.push(value);
+  assert.equal(matches.length, expected.length);
+  matches.forEach((match, i) => {
+    const expectedRange = expected[i];
+    const expectedStartContainer = evaluateXPath(doc, expectedRange.startContainerXPath);
+    const expectedEndContainer = evaluateXPath(doc, expectedRange.endContainerXPath);
+    assert(match.startContainer === expectedStartContainer,
+      `unexpected start container: ${prettyNodeName(match.startContainer)}; `
+      + `expected ${prettyNodeName(expectedStartContainer)}`
+    );
+    assert.equal(match.startOffset, expectedRange.startOffset);
+    assert(match.endContainer === evaluateXPath(doc, expectedRange.endContainerXPath),
+      `unexpected end container: ${prettyNodeName(match.endContainer)}; `
+      + `expected ${prettyNodeName(expectedEndContainer)}`
+    );
+    assert.equal(match.endOffset, expectedRange.endOffset);
+  });
+}
+
+function prettyNodeName(node: Node) {
+  switch (node.nodeType) {
+    case Node.TEXT_NODE:
+      const text = (node as Text).nodeValue;
+      return `#text "${text.length > 50 ? text.substring(0, 50) + '…' : text}"`;
+    case Node.ELEMENT_NODE:
+      return `<${(node as Element).tagName.toLowerCase()}>`;
+    default:
+      return node.nodeName.toLowerCase();
+  }
+}
diff --git a/packages/dom/test/text-quote/utils.ts b/packages/dom/test/text-quote/utils.ts
new file mode 100644
index 0000000..a59fb06
--- /dev/null
+++ b/packages/dom/test/text-quote/utils.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { assert } from "chai";
+
+// RangeInfo serialises a Range’s start and end containers as XPaths.
+export type RangeInfo = {
+  startContainerXPath: string,
+  startOffset: number,
+  endContainerXPath: string,
+  endOffset: number,
+};
+
+export function evaluateXPath(doc: Document, xpath: string): Node {
+  const result = doc.evaluate(xpath, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
+  const nodes = new Array(result.snapshotLength).fill(undefined).map((_, i) => result.snapshotItem(i));
+  assert.equal(nodes.length, 1,
+    `Test suite contains XPath with ${nodes.length} results instead of 1: '${xpath}'`
+  );
+  return nodes[0];
+}
+
+export function hydrateRange(rangeInfo: RangeInfo, doc: Document): Range {
+  const range = doc.createRange();
+  range.setStart(evaluateXPath(doc, rangeInfo.startContainerXPath), rangeInfo.startOffset);
+  range.setEnd(evaluateXPath(doc, rangeInfo.endContainerXPath), rangeInfo.endOffset);
+  return range;
+}
diff --git a/test/data-model.ts b/test/data-model.test.ts
similarity index 100%
rename from test/data-model.ts
rename to test/data-model.test.ts
diff --git a/web/demo/index.js b/web/demo/index.js
index 9cf64d7..88b9a06 100644
--- a/web/demo/index.js
+++ b/web/demo/index.js
@@ -127,13 +127,7 @@
   const range = selection.getRangeAt(0);
   if (range.collapsed) return;
 
-  const scope = document.createRange();
-  scope.selectNodeContents(source);
-
-  if (!scope.isPointInRange(range.startContainer, range.startOffset)) return;
-  if (!scope.isPointInRange(range.endContainer, range.endOffset)) return;
-
-  return describeTextQuote(range, scope);
+  return describeTextQuote(range, source);
 }
 
 async function onSelectionChange() {
diff --git a/web/webpack.config.js b/web/webpack.config.js
index 8da5b59..5dcbfda 100644
--- a/web/webpack.config.js
+++ b/web/webpack.config.js
@@ -30,7 +30,7 @@
     demo: ['./demo/index.html', './demo/index.js'],
     test: [
       './test/index.html',
-      'mocha-loader!multi-entry-loader?include=./packages/*/test/**/*.[jt]s!',
+      'mocha-loader!multi-entry-loader?include=./packages/*/test/**/*.test.[jt]s!',
     ],
   },
   resolve: {
diff --git a/yarn.lock b/yarn.lock
index 2cb91e5..fcfea75 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2124,6 +2124,11 @@
     jsonparse "^1.2.0"
     through ">=2.2.7 <3"
 
+abab@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a"
+  integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==
+
 abbrev@1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@@ -2137,11 +2142,24 @@
     mime-types "~2.1.24"
     negotiator "0.6.2"
 
+acorn-globals@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-6.0.0.tgz#46cdd39f0f8ff08a876619b55f5ac8a6dc770b45"
+  integrity sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==
+  dependencies:
+    acorn "^7.1.1"
+    acorn-walk "^7.1.1"
+
 acorn-jsx@^5.1.0:
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384"
   integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw==
 
+acorn-walk@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e"
+  integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==
+
 acorn@^6.2.1:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e"
@@ -2152,6 +2170,11 @@
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c"
   integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==
 
+acorn@^7.1.1:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.2.0.tgz#17ea7e40d7c8640ff54a694c889c26f31704effe"
+  integrity sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ==
+
 agent-base@4, agent-base@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
@@ -2220,11 +2243,6 @@
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
-ancestors@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/ancestors/-/ancestors-0.0.3.tgz#124eb944447d68b302057047d15d077a9da5179d"
-  integrity sha1-Ek65RER9aLMCBXBH0V0Hep2lF50=
-
 ansi-colors@3.2.3:
   version "3.2.3"
   resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813"
@@ -2721,6 +2739,11 @@
   resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
   integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=
 
+browser-process-hrtime@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626"
+  integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==
+
 browser-stdout@1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
@@ -3625,6 +3648,23 @@
   resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
   integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
 
+cssom@^0.4.4:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10"
+  integrity sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==
+
+cssom@~0.3.6:
+  version "0.3.8"
+  resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a"
+  integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==
+
+cssstyle@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-2.3.0.tgz#ff665a0ddbdc31864b09647f34163443d90b0852"
+  integrity sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==
+  dependencies:
+    cssom "~0.3.6"
+
 currently-unhandled@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
@@ -3651,6 +3691,15 @@
   dependencies:
     assert-plus "^1.0.0"
 
+data-urls@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b"
+  integrity sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==
+  dependencies:
+    abab "^2.0.3"
+    whatwg-mimetype "^2.3.0"
+    whatwg-url "^8.0.0"
+
 date-fns@^1.27.2:
   version "1.30.1"
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
@@ -3712,6 +3761,11 @@
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
 
+decimal.js@^10.2.0:
+  version "10.2.0"
+  resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231"
+  integrity sha512-vDPw+rDgn3bZe1+F/pyEwb1oMG2XTlRVgAa6B4KccTEpYgF8w6eQllVbQcfIJnZyvzFtFpxnpGtx8dd7DJp/Rw==
+
 decode-uri-component@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
@@ -3922,24 +3976,23 @@
   dependencies:
     esutils "^2.0.2"
 
-dom-node-iterator@^3.5.3:
-  version "3.5.3"
-  resolved "https://registry.yarnpkg.com/dom-node-iterator/-/dom-node-iterator-3.5.3.tgz#32b68aa440962f1734487029f544a3db704637b7"
-  integrity sha1-MraKpECWLxc0SHAp9USj23BGN7c=
-
-dom-seek@^4.0.3:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/dom-seek/-/dom-seek-4.0.3.tgz#f14dddf04b3fb062d901c7b00b0c142a06e0a94b"
-  integrity sha1-8U3d8Es/sGLZAcewCwwUKgbgqUs=
-  dependencies:
-    ancestors "0.0.3"
-    index-of "^0.2.0"
+dom-seek@^5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/dom-seek/-/dom-seek-5.1.1.tgz#4e35bee763b6ba082f372345823ec9665d1fbf26"
+  integrity sha512-1strSwd201Gfhfkfsk77SX9xyJGzu12gqUo5Q0W3Njtj2QxcfQTwCDOynZ6npZ4ASUFRQq0asjYDRlFxYPKwTA==
 
 domain-browser@^1.1.1:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
   integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==
 
+domexception@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304"
+  integrity sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==
+  dependencies:
+    webidl-conversions "^5.0.0"
+
 dot-prop@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177"
@@ -4164,6 +4217,18 @@
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
+escodegen@^1.14.1:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.1.tgz#ba01d0c8278b5e95a9a45350142026659027a457"
+  integrity sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ==
+  dependencies:
+    esprima "^4.0.1"
+    estraverse "^4.2.0"
+    esutils "^2.0.2"
+    optionator "^0.8.1"
+  optionalDependencies:
+    source-map "~0.6.1"
+
 eslint-config-prettier@^6.9.0:
   version "6.9.0"
   resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.9.0.tgz#430d24822e82f7deb1e22a435bfa3999fae4ad64"
@@ -4327,7 +4392,7 @@
     acorn-jsx "^5.1.0"
     eslint-visitor-keys "^1.1.0"
 
-esprima@^4.0.0:
+esprima@^4.0.0, esprima@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
   integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
@@ -4346,7 +4411,7 @@
   dependencies:
     estraverse "^4.1.0"
 
-estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1:
+estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
   integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
@@ -5103,6 +5168,11 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+global-jsdom@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/global-jsdom/-/global-jsdom-6.1.0.tgz#a911ec57c51cf72e93a2ce97925a02a6427aed76"
+  integrity sha512-zaNNWr7hpov5SgF21fVtvnliRcRMYSZGc47nSipUOw5Ktft+2njD4hjzN1OXWQzlBFnRU/W+MBN6OvaPMTawKQ==
+
 global-modules@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
@@ -5375,6 +5445,13 @@
     readable-stream "^2.0.1"
     wbuf "^1.1.0"
 
+html-encoding-sniffer@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3"
+  integrity sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==
+  dependencies:
+    whatwg-encoding "^1.0.5"
+
 html-entities@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f"
@@ -5604,11 +5681,6 @@
   resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
   integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
 
-index-of@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/index-of/-/index-of-0.2.0.tgz#38c1e2367ea55dffad3b6eb592ec1cc3090d7d65"
-  integrity sha1-OMHiNn6lXf+tO261kuwcwwkNfWU=
-
 indexes-of@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
@@ -5985,6 +6057,11 @@
   dependencies:
     isobject "^4.0.0"
 
+is-potential-custom-element-name@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
+  integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c=
+
 is-promise@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
@@ -6184,6 +6261,38 @@
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
+jsdom@^16.2.2:
+  version "16.2.2"
+  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.2.2.tgz#76f2f7541646beb46a938f5dc476b88705bedf2b"
+  integrity sha512-pDFQbcYtKBHxRaP55zGXCJWgFHkDAYbKcsXEK/3Icu9nKYZkutUXfLBwbD+09XDutkYSHcgfQLZ0qvpAAm9mvg==
+  dependencies:
+    abab "^2.0.3"
+    acorn "^7.1.1"
+    acorn-globals "^6.0.0"
+    cssom "^0.4.4"
+    cssstyle "^2.2.0"
+    data-urls "^2.0.0"
+    decimal.js "^10.2.0"
+    domexception "^2.0.1"
+    escodegen "^1.14.1"
+    html-encoding-sniffer "^2.0.1"
+    is-potential-custom-element-name "^1.0.0"
+    nwsapi "^2.2.0"
+    parse5 "5.1.1"
+    request "^2.88.2"
+    request-promise-native "^1.0.8"
+    saxes "^5.0.0"
+    symbol-tree "^3.2.4"
+    tough-cookie "^3.0.1"
+    w3c-hr-time "^1.0.2"
+    w3c-xmlserializer "^2.0.0"
+    webidl-conversions "^6.0.0"
+    whatwg-encoding "^1.0.5"
+    whatwg-mimetype "^2.3.0"
+    whatwg-url "^8.0.0"
+    ws "^7.2.3"
+    xml-name-validator "^3.0.0"
+
 jsesc@^2.5.1:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
@@ -7368,6 +7477,11 @@
   resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
   integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
 
+nwsapi@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7"
+  integrity sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==
+
 nyc@^15.0.0:
   version "15.0.0"
   resolved "https://registry.yarnpkg.com/nyc/-/nyc-15.0.0.tgz#eb32db2c0f29242c2414fe46357f230121cfc162"
@@ -7546,7 +7660,7 @@
     minimist "~0.0.1"
     wordwrap "~0.0.2"
 
-optionator@^0.8.3:
+optionator@^0.8.1, optionator@^0.8.3:
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
   integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==
@@ -7823,6 +7937,11 @@
     parse-path "^4.0.0"
     protocols "^1.4.0"
 
+parse5@5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
+  integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
+
 parseurl@~1.3.2, parseurl@~1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -8203,6 +8322,11 @@
   resolved "https://registry.yarnpkg.com/psl/-/psl-1.4.0.tgz#5dd26156cdb69fa1fdb8ab1991667d3f80ced7c2"
   integrity sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw==
 
+psl@^1.1.28:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
+  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
+
 public-encrypt@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"
@@ -8250,7 +8374,7 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
   integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
 
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
@@ -8622,6 +8746,22 @@
   dependencies:
     is-finite "^1.0.0"
 
+request-promise-core@1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9"
+  integrity sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==
+  dependencies:
+    lodash "^4.17.15"
+
+request-promise-native@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.8.tgz#a455b960b826e44e2bf8999af64dff2bfe58cb36"
+  integrity sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==
+  dependencies:
+    request-promise-core "1.1.3"
+    stealthy-require "^1.1.1"
+    tough-cookie "^2.3.3"
+
 request@^2.87.0:
   version "2.88.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
@@ -8648,6 +8788,32 @@
     tunnel-agent "^0.6.0"
     uuid "^3.3.2"
 
+request@^2.88.2:
+  version "2.88.2"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
+  integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
+  dependencies:
+    aws-sign2 "~0.7.0"
+    aws4 "^1.8.0"
+    caseless "~0.12.0"
+    combined-stream "~1.0.6"
+    extend "~3.0.2"
+    forever-agent "~0.6.1"
+    form-data "~2.3.2"
+    har-validator "~5.1.3"
+    http-signature "~1.2.0"
+    is-typedarray "~1.0.0"
+    isstream "~0.1.2"
+    json-stringify-safe "~5.0.1"
+    mime-types "~2.1.19"
+    oauth-sign "~0.9.0"
+    performance-now "^2.1.0"
+    qs "~6.5.2"
+    safe-buffer "^5.1.2"
+    tough-cookie "~2.5.0"
+    tunnel-agent "^0.6.0"
+    uuid "^3.3.2"
+
 require-directory@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@@ -8852,6 +9018,13 @@
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
+saxes@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d"
+  integrity sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==
+  dependencies:
+    xmlchars "^2.2.0"
+
 schema-utils@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770"
@@ -9320,6 +9493,11 @@
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
 
+stealthy-require@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
+  integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
+
 stream-browserify@^2.0.1:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"
@@ -9579,6 +9757,11 @@
   resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
   integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
 
+symbol-tree@^3.2.4:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
+  integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
+
 table@^5.2.3:
   version "5.4.6"
   resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
@@ -9767,6 +9950,23 @@
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
   integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
 
+tough-cookie@^2.3.3:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
+  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
+  dependencies:
+    psl "^1.1.28"
+    punycode "^2.1.1"
+
+tough-cookie@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2"
+  integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==
+  dependencies:
+    ip-regex "^2.1.0"
+    psl "^1.1.28"
+    punycode "^2.1.1"
+
 tough-cookie@~2.4.3:
   version "2.4.3"
   resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
@@ -9782,6 +9982,13 @@
   dependencies:
     punycode "^2.1.0"
 
+tr46@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.0.2.tgz#03273586def1595ae08fedb38d7733cee91d2479"
+  integrity sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==
+  dependencies:
+    punycode "^2.1.1"
+
 trim-newlines@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
@@ -10085,9 +10292,23 @@
     extsprintf "^1.2.0"
 
 vm-browserify@^1.0.1:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019"
-  integrity sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw==
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
+  integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
+
+w3c-hr-time@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"
+  integrity sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==
+  dependencies:
+    browser-process-hrtime "^1.0.0"
+
+w3c-xmlserializer@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz#3e7104a05b75146cc60f564380b7f683acf1020a"
+  integrity sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==
+  dependencies:
+    xml-name-validator "^3.0.0"
 
 watchpack@^1.6.0:
   version "1.6.0"
@@ -10121,6 +10342,16 @@
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
   integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
 
+webidl-conversions@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
+  integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==
+
+webidl-conversions@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514"
+  integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==
+
 webpack-cli@^3.3.10:
   version "3.3.10"
   resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.10.tgz#17b279267e9b4fb549023fae170da8e6e766da13"
@@ -10247,6 +10478,18 @@
   resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29"
   integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==
 
+whatwg-encoding@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0"
+  integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==
+  dependencies:
+    iconv-lite "0.4.24"
+
+whatwg-mimetype@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf"
+  integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
+
 whatwg-url@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.0.0.tgz#fde926fa54a599f3adf82dff25a9f7be02dc6edd"
@@ -10256,6 +10499,15 @@
     tr46 "^1.0.1"
     webidl-conversions "^4.0.2"
 
+whatwg-url@^8.0.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.1.0.tgz#c628acdcf45b82274ce7281ee31dd3c839791771"
+  integrity sha512-vEIkwNi9Hqt4TV9RdnaBPNt+E2Sgmo3gePebCRgZ1R7g6d23+53zCTnuB0amKI4AXq6VM8jj2DUAa0S1vjJxkw==
+  dependencies:
+    lodash.sortby "^4.7.0"
+    tr46 "^2.0.2"
+    webidl-conversions "^5.0.0"
+
 which-module@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
@@ -10415,6 +10667,21 @@
   dependencies:
     async-limiter "~1.0.0"
 
+ws@^7.2.3:
+  version "7.3.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd"
+  integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==
+
+xml-name-validator@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
+  integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
+
+xmlchars@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
+  integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
+
 xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"