Repository: exadel-inc/esl (ts)

Issue:
[🚀esl-traversing-query]: add ability to find closest element
As an ESL consumer, I want to be able to use the 'closest' traversing operation.

## Operation definition:
`::closet(sel)` = `::is(sel)` or `::parent(sel)`  - find the closest element parent starting and including the current element

## Implementation details

`esl-utils/dom/traversing` should be extended with the `findClosest` function or ability to include the current element for "sequence finder"-based utils.

##Example 
- `TraversingQuery.first($el, '::closest(div)')`
- `findClosest($el, 'div')` or `findPrent($el, 'div', /*Include initial*/ true)`

**Priority**: Normal

[🚀esl-utils]: add ability to pass predicate to sequence finder
As an ESL consumer, I want to be able to pass the predicate function instead of the selector

**Extended finder API**
```ts
/** Create function that finds next dom element, that matches selector, in the sequence declared by `next` function */
export const createSequenceFinder = (next: (el: Element) => Element | null) => {
  return function (base: Element, sel: string | (el: Element) => boolean): Element | null {
     ...
  };
};
```

**Priority**: Normal


Pull request description:
feat(esl-utils, esl-traversing-query): update createSequenceFinder, add possibility to find closest element
[esl-utils]: add ability to pass predicate to createSequenceFinder function
[esl-traversing-query]: add ability to find closest element

In the scope of this PR I reworked some tests related to these topics.

[Closes #930 ](https://github.com/exadel-inc/esl/issues/930)
[Closes #929 ](https://github.com/exadel-inc/esl/issues/929)

Patch:
diff --git a/src/modules/esl-traversing-query/README.md b/src/modules/esl-traversing-query/README.md
index fb18c9fd6..021dbc4a6 100644
--- a/src/modules/esl-traversing-query/README.md
+++ b/src/modules/esl-traversing-query/README.md
@@ -13,7 +13,7 @@ Extended query supports:
  - relative selectors  
 NOTE: Selectors that don't start from a plain selector will use passed base Element as a root
  - `::next` and `::prev` sibling pseudo-selectors
- - `::parent` and `::child` pseudo-selectors
+ - `::parent`, `::closest` and `::child` pseudo-selectors
  - `::find` pseudo-selector
  - `::first`, `::last` and `:nth(#)` limitation pseudo-selectors
 
@@ -27,6 +27,7 @@ NOTE: Selectors that don't start from a plain selector will use passed base Elem
 - `::prev` - get previous sibling element
 - `::parent` - get base element parent
 - `::parent(#id .class [attr])` - find the closest parent, matching passed selector
+- `::closest(#id .class [attr])` - find the closest ancestor including base element that matches passed selector
 - `::child(#id .class [attr])` - find direct child element(s) that match passed selector
 - `::find(#id .class [attr])` - find child element(s) that match passed selector
 - `::parent::child(some-tag)` - find direct child element(s) that match tag 'some-tag' in the parent
diff --git a/src/modules/esl-traversing-query/core/esl-traversing-query.ts b/src/modules/esl-traversing-query/core/esl-traversing-query.ts
index 6c9d7b5d2..8b2f5a1a2 100644
--- a/src/modules/esl-traversing-query/core/esl-traversing-query.ts
+++ b/src/modules/esl-traversing-query/core/esl-traversing-query.ts
@@ -1,7 +1,7 @@
 import {ExportNs} from '../../esl-utils/environment/export-ns';
 import {tuple, wrap, uniq} from '../../esl-utils/misc/array';
 import {unwrapParenthesis} from '../../esl-utils/misc/format';
-import {findAll, findChildren, findNext, findParent, findPrev} from '../../esl-utils/dom/traversing';
+import {findAll, findChildren, findNext, findParent, findClosest, findPrev} from '../../esl-utils/dom/traversing';
 
 type ProcessorDescriptor = [string?, string?];
 type ElementProcessor = (base: Element, sel: string) => Element | Element[] | null;
@@ -13,7 +13,7 @@ type CollectionProcessor = (els: Element[], sel: string) => Element[];
  * - plain CSS selectors
  * - relative selectors (selectors that don't start from a plain selector will use passed base Element as a root)
  * - ::next and ::prev sibling pseudo-selectors
- * - ::parent and ::child pseudo-selectors
+ * - ::parent, ::closest and ::child pseudo-selectors
  * - ::find pseudo-selector
  * - ::first, ::last and :nth(#) limitation pseudo-selectors
  * - ::filter, ::not filtration pseudo-selectors
@@ -25,6 +25,7 @@ type CollectionProcessor = (els: Element[], sel: string) => Element[];
  * - `::prev` - get previous sibling element
  * - `::parent` - get base element parent
  * - `::parent(#id .class [attr])` - find the closest parent matching passed selector
+ * - `::closest(#id .class [attr])` - find the closest ancestor including base element that matches passed selector
  * - `::child(#id .class [attr])` - find direct child element(s) that match passed selector
  * - `::find(#id .class [attr])` - find child element(s) that match passed selector
  * - `::find(buttons, a)::not([hidden])` - find all buttons and anchors that are not have hidden attribute
@@ -40,7 +41,8 @@ export class TraversingQuery {
     '::next': findNext,
     '::prev': findPrev,
     '::child': findChildren,
-    '::parent': findParent
+    '::parent': findParent,
+    '::closest': findClosest
   };
   private static COLLECTION_PROCESSORS: Record<string, CollectionProcessor> = {
     '::first': (list: Element[]) => list.slice(0, 1),
@@ -55,7 +57,7 @@ export class TraversingQuery {
 
   /**
    * @returns RegExp that selects all known processors in query string
-   * e.g. /(::parent|::child|::next|::prev)/
+   * e.g. /(::parent|::closest|::child|::next|::prev)/
    */
   private static get PROCESSORS_REGEX(): RegExp {
     const keys = Object.keys(this.ELEMENT_PROCESSORS).concat(Object.keys(this.COLLECTION_PROCESSORS));
diff --git a/src/modules/esl-utils/dom/traversing.ts b/src/modules/esl-utils/dom/traversing.ts
index 0b081541f..d55ceb374 100644
--- a/src/modules/esl-utils/dom/traversing.ts
+++ b/src/modules/esl-utils/dom/traversing.ts
@@ -1,13 +1,18 @@
+import type {Predicate} from '../misc/functions';
 /** Check that `nodeA` and `nodeB` are from the same tree path */
 export const isRelativeNode = (nodeA: Node | null, nodeB: Node | null): boolean => {
   return !!(nodeA && nodeB) && (nodeA.contains(nodeB) || nodeB.contains(nodeA));
 };
 
+type IteratorFn = (el: Element) => Element | null;
+
 /** Create function that finds next dom element, that matches selector, in the sequence declared by `next` function */
-export const createSequenceFinder = (next: (el: Element) => Element | null) => {
-  return function (base: Element, sel: string): Element | null {
-    for (let target: Element | null = next(base); target; target = next(target)) {
-      if (!sel || target.matches(sel)) return target;
+export const createSequenceFinder = (next: IteratorFn, includeSelf: boolean = false) => {
+  return function (base: Element, predicate: string | Predicate<Element>): Element | null {
+    if (!predicate) return includeSelf ? base : next(base);
+    for (let target: Element | null = includeSelf ? base : next(base); target; target = next(target)) {
+      if (typeof predicate === 'string' && target.matches(predicate)) return target;
+      if (typeof predicate === 'function' && predicate.call(target, target)) return target;
     }
     return null;
   };
@@ -26,6 +31,8 @@ export const findNext = createSequenceFinder((el) => el.nextElementSibling);
 export const findPrev = createSequenceFinder((el) => el.previousElementSibling);
 /** @returns first matching parent or null*/
 export const findParent = createSequenceFinder((el) => el.parentElement);
+/** @returns first matching ancestor starting from passed element or null*/
+export const findClosest = createSequenceFinder((el) => el.parentElement, true);
 
 /** @returns Array of all matching elements in subtree or empty array */
 export const findAll = (base: Element, sel: string): Element[] => {
@@ -58,6 +65,7 @@ export abstract class TraversingUtils {
   static findNext = findNext;
   static findPrev = findPrev;
   static findParent = findParent;
+  static findClosest = findClosest;
 
   static findAll = findAll;
   static findChildren = findChildren;
diff --git a/src/modules/esl-utils/misc/functions.ts b/src/modules/esl-utils/misc/functions.ts
index cdc179eea..09508b4cd 100644
--- a/src/modules/esl-utils/misc/functions.ts
+++ b/src/modules/esl-utils/misc/functions.ts
@@ -12,6 +12,8 @@ export type AnyToAnyFnSignature = (...args: any[]) => any;
 
 export type MethodTypedDecorator<T> = (target: any, property: string, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
 
+export type Predicate<T> = (el: T) => boolean;
+
 /**
  * Property provider function
  * @param that - (equal to `this` context) host or context


Test patch:
diff --git a/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts b/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
index 9ff01e16c..efd755e06 100644
--- a/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
+++ b/src/modules/esl-traversing-query/test/esl-traversing-query.test.ts
@@ -31,99 +31,142 @@ describe('Traversing Query tests', () => {
   const article1 = document.querySelector('.col-1') as HTMLElement;
   const article2 = document.querySelector('.col-2') as HTMLElement;
 
-  test.each([
-    '::next',
-    '::prev',
-    '::find',
-    '::find(.btn)',
-    '::child',
-    '::child(.btn)',
-    '::parent',
-    '::parent(body)'
-  ])('Null check: TraversingQuery.all/one(%s, null)', (sel) => {
-    expect(TraversingQuery.first(sel)).toBe(null);
-    expect(TraversingQuery.all(sel)).toEqual([]);
-  });
-
-  test.each([
-    // Simple queries
-    ['#null', undefined, []],
-    ['#btn3', undefined, [btn3]],
-    ['#btn3', null, [btn3]],
-    ['section', undefined, [root]],
-    ['section > .row', undefined, [row1, row2]],
-    ['section > .row:first-child', undefined, [row1]],
-
-    // Next query test
-    ['::next', btn1, [btn2]],
-    ['::next', btn6, []],
-    ['::next(article)', btn1, [article1]],
-    ['::next(article.col-1)', btn1, [article1]],
-    ['::next(article.col-2)', btn1, []],
-
-    // Prev query test
-    ['::prev', btn2, [btn1]],
-    ['::prev', btn1, []],
-    ['::prev(.btn)', article1, [btn5]],
-    ['::prev(#btn2)', article1, [btn2]],
-    ['::prev(#btn6)', article1, []],
-
-    // Child query test
-    ['::child', article1, []],
-    ['::child', row1, [btn1, btn2, btn3, btn4, btn5, article1]],
-    ['::child(#btn2)', row1, [btn2]],
-    ['::child(.btn)', article1, []],
-
-    // Parent query test
-    ['::parent', article1, [row1]],
-    ['::parent', root, [document.body]],
-    ['::parent(.container)', btn1, [root]],
-    ['::parent(.btn)', btn1, []],
-
-    // Find query test
-    ['::find', root, [root]],
-    ['::find(.btn)', root, [btn1, btn2, btn3, btn4, btn5, btn6]],
-    ['::find(.container)', root, []],
-    ['::find(#row1 .btn)', root, [btn1, btn2, btn3, btn4, btn5]],
-
-    // Postprocessors
-    ['::find(.btn)::first', root, [btn1]],
-    ['::find(.btn)::last', root, [btn6]],
-    ['::find(.btn)::nth(1)', root, [btn1]],
-    ['::find(.btn)::nth(4)', root, [btn4]],
-    ['::find(.btn)::nth(6)', root, [btn6]],
-    ['::find(.btn)::nth', root, []],
-    ['::find(.btn)::nth(8)', root, []],
-    ['::find(.btn)::nth(bla bla)', root, []],
-
-    // Filters
-    ['::find(.btn)::filter(.un-existing)', root, []],
-    ['::find(.btn)::not(button)', root, []],
-    ['::find(.btn)::filter([data-test])', root, [btn2, btn5, btn6]],
-    ['::find(.btn)::not([data-test])', root, [btn1, btn3, btn4]],
-    ['::find(.btn)::filter(:first-child)', root, [btn1]],
-    ['::find(.btn)::not(:first-child)', root, [btn2, btn3, btn4, btn5, btn6]],
-
-    // Complex query test
-    ['::parent::child', root, [root]],
-    ['::parent::find(.btn)', btn5, [btn1, btn2, btn3, btn4, btn5]],
-    ['::parent::next::find(.col-2)', article1, [article2]],
-    ['::parent(.container)::find(.btn)::last', btn5, [btn6]],
-    ['::parent(.container)::child(.row)::last::find(.col-2)', article1, [article2]],
-  ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p.', (sel, base, expectedCollection) => {
+  const traversingQueryWrap = (sel: any, base: any, expectedCollection: any) => {
     expect(TraversingQuery.first(sel, base as Element | null))
       .toBe(expectedCollection.length > 0 ? expectedCollection[0] : null);
     expect(TraversingQuery.all(sel, base as Element | null))
       .toEqual(expectedCollection);
+  };
+
+  describe('test cases when base element is absent', () => {
+    test.each([
+      '::next',
+      '::prev',
+      '::find',
+      '::find(.btn)',
+      '::child',
+      '::child(.btn)',
+      '::parent',
+      '::parent(body)',
+      '::closest',
+      '::closest(.btn)'
+    ])('Null check: TraversingQuery.all/one(%s, null)', (sel) => {
+      expect(TraversingQuery.first(sel)).toBe(null);
+      expect(TraversingQuery.all(sel)).toEqual([]);
+    });
   });
 
-  test.each([
-    ['#row1', null, root, [row1]],
-    ['#row1', null, row2, []],
-  ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p., Scope: %p.', (sel, base, scope, expectedCollection) => {
-    expect(TraversingQuery.first(sel, base as Element | null, scope as Element))
-      .toBe(expectedCollection.length > 0 ? expectedCollection[0] : null);
-    expect(TraversingQuery.all(sel, base as Element | null, scope as Element))
-      .toEqual(expectedCollection);
+  describe('queries with plain selectors', () => {
+    test.each([
+      ['#null', undefined, []],
+      ['#btn3', undefined, [btn3]],
+      ['#btn3', null, [btn3]],
+      ['section', undefined, [root]],
+      ['section > .row', undefined, [row1, row2]],
+      ['section > .row:first-child', undefined, [row1]]
+    ])('Main check: TraversingQuery.el: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('pseudo-selector ::next', () => {
+    test.each([
+      ['::next', btn1, [btn2]],
+      ['::next', btn6, []],
+      ['::next(article)', btn1, [article1]],
+      ['::next(article.col-1)', btn1, [article1]],
+      ['::next(article.col-2)', btn1, []]
+    ])('Main check: TraversSel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('pseudo-selector ::prev', () => {
+    test.each([
+      ['::prev', btn2, [btn1]],
+      ['::prev', btn1, []],
+      ['::prev(.btn)', article1, [btn5]],
+      ['::prev(#btn2)', article1, [btn2]],
+      ['::prev(#btn6)', article1, []]
+    ])('Main check: TraversingQuery: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('pseudo-selector ::child', () => {
+    test.each([
+      ['::child', article1, []],
+      ['::child', row1, [btn1, btn2, btn3, btn4, btn5, article1]],
+      ['::child(#btn2)', row1, [btn2]],
+      ['::child(.btn)', article1, []]
+    ])('Main check: TraversingQuel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('pseudo-selector ::parent', () => {
+    test.each([
+      ['::parent', article1, [row1]],
+      ['::parent', root, [document.body]],
+      ['::parent(.container)', btn1, [root]],
+      ['::parent(.btn)', btn1, []]
+    ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('pseudo-selector ::closest', () => {
+    test.each([
+      ['::closest', btn1, [btn1]],
+      ['::closest(.btn)', btn1, [btn1]],
+      ['::closest(#row1)', btn1, [row1]],
+      ['::closest(.btn)', row1, []],
+    ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('pseudo-selector ::find', () => {
+    test.each([
+      ['::find', root, [root]],
+      ['::find(.btn)', root, [btn1, btn2, btn3, btn4, btn5, btn6]],
+      ['::find(.container)', root, []],
+      ['::find(#row1 .btn)', root, [btn1, btn2, btn3, btn4, btn5]]
+    ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('postprocessors (::first, ::last, ::nth)', () => {
+    test.each([
+      ['::find(.btn)::first', root, [btn1]],
+      ['::find(.btn)::last', root, [btn6]],
+      ['::find(.btn)::nth(1)', root, [btn1]],
+      ['::find(.btn)::nth(4)', root, [btn4]],
+      ['::find(.btn)::nth(6)', root, [btn6]],
+      ['::find(.btn)::nth', root, []],
+      ['::find(.btn)::nth(8)', root, []],
+      ['::find(.btn)::nth(bla bla)', root, []]
+    ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('filters (::filter, ::not)', () => {
+    test.each([
+      ['::find(.btn)::filter(.un-existing)', root, []],
+      ['::find(.btn)::not(button)', root, []],
+      ['::find(.btn)::filter([data-test])', root, [btn2, btn5, btn6]],
+      ['::find(.btn)::not([data-test])', root, [btn1, btn3, btn4]],
+      ['::find(.btn)::filter(:first-child)', root, [btn1]],
+      ['::find(.btn)::not(:first-child)', root, [btn2, btn3, btn4, btn5, btn6]]
+    ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('queries using several pseudo-selectors and plain selectors', () => {
+    test.each([
+      ['::parent::child', root, [root]],
+      ['::parent::find(.btn)', btn5, [btn1, btn2, btn3, btn4, btn5]],
+      ['::parent::next::find(.col-2)', article1, [article2]],
+      ['::parent(.container)::find(.btn)::last', btn5, [btn6]],
+      ['::parent(.container)::child(.row)::last::find(.col-2)', article1, [article2]],
+      ['::closest(.row)::find(.col-1)', btn1, [article1]]
+    ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p.', traversingQueryWrap);
+  });
+
+  describe('test cases when scope is defined', () => {
+    test.each([
+      ['#row1', null, root, [row1]],
+      ['#row1', null, row2, []]
+    ])('Main check: TraversingQuery.all/one, Sel: %s, Base: %p., Scope: %p.', (sel, base, scope, expectedCollection) => {
+      expect(TraversingQuery.first(sel, base as Element | null, scope as Element))
+        .toBe(expectedCollection.length > 0 ? expectedCollection[0] : null);
+      expect(TraversingQuery.all(sel, base as Element | null, scope as Element))
+        .toEqual(expectedCollection);
+    });
   });
 });
diff --git a/src/modules/esl-utils/dom/test/traversing.test.ts b/src/modules/esl-utils/dom/test/traversing.test.ts
index 8326c052a..2af882a47 100644
--- a/src/modules/esl-utils/dom/test/traversing.test.ts
+++ b/src/modules/esl-utils/dom/test/traversing.test.ts
@@ -1,4 +1,4 @@
-import {isRelativeNode, findClosestBy, isMatches} from '../traversing';
+import {isRelativeNode, findClosestBy, isMatches, findNext, findPrev, findParent, findClosest, findAll, findChildren} from '../traversing';
 
 describe('Common: dom/traversing helper tests', () => {
   document.body.innerHTML = `
@@ -24,32 +24,108 @@ describe('Common: dom/traversing helper tests', () => {
   const row1 = document.querySelector('#row1') as HTMLDivElement;
   const btn1 = document.querySelector('#btn1') as HTMLButtonElement;
   const btn2 = document.querySelector('#btn2') as HTMLButtonElement;
+  const btn3 = document.querySelector('#btn3') as HTMLButtonElement;
+  const btn4 = document.querySelector('#btn4') as HTMLButtonElement;
+  const btn5 = document.querySelector('#btn5') as HTMLButtonElement;
+  const row2 = document.querySelector('#row2') as HTMLDivElement;
   const article1 = document.querySelector('.col-1') as HTMLElement;
 
-  test('isRelativeNode', () => {
-    expect(isRelativeNode(document.body, btn1)).toBeTruthy();
-    expect(isRelativeNode(btn1, root)).toBeTruthy();
-    expect(isRelativeNode(document.body, document.createElement('div'))).toBeFalsy();
-    expect(isRelativeNode(btn1, btn2)).toBeFalsy();
+  describe('isRelativeNode', () => {
+    test('checks that both nodes are from the same tree path and returns true if they are', () => {
+      expect(isRelativeNode(document.body, btn1)).toBeTruthy();
+      expect(isRelativeNode(btn1, root)).toBeTruthy();
+    });
+    test('returns false if nodes are not from the same tree path', () => {
+      expect(isRelativeNode(document.body, document.createElement('div'))).toBeFalsy();
+      expect(isRelativeNode(btn1, btn2)).toBeFalsy();
+    });
   });
 
-  test('isMatches', () => {
-    expect(isMatches(div, (el) => div === el)).toBeTruthy();
-    expect(isMatches(div, (el) => root === el)).toBeFalsy();
-    expect(isMatches(div, 'div')).toBeTruthy();
-    expect(isMatches(div, 'span')).toBeFalsy();
-    expect(isMatches(div)).toBeFalsy();
+  describe('isMatches', () => {
+    test('returns true if element matches passed selector or exact predicate function', () => {
+      expect(isMatches(div, (el) => div === el)).toBeTruthy();
+      expect(isMatches(div, 'div')).toBeTruthy();
+    });
+    test('returns false if element does not match passed selector or exact predicate function', () => {
+      expect(isMatches(div, (el) => root === el)).toBeFalsy();
+      expect(isMatches(div, 'span')).toBeFalsy();
+      expect(isMatches(div)).toBeFalsy();
+    });
   });
 
-  test('closestBy', () => {
-    expect(findClosestBy(null, (el: HTMLElement) => el.dataset.test === '1')).toBe(null);
-    expect(findClosestBy(document.createElement('div'), (el: HTMLElement) => el.dataset.test === '1')).toBe(null);
-    expect(findClosestBy(btn2, (el: HTMLElement) => el.classList.contains('btn'))).toBe(btn2);
-    expect(findClosestBy(btn2, (el: HTMLElement) => el.dataset.test === '1')).toBe(row1);
-    expect(findClosestBy(btn2, (el: HTMLElement) => el.dataset.test === '1', true)).toBe(row1);
-    expect(findClosestBy(btn2, (el: HTMLElement) => el.tagName.toLowerCase() === 'section')).toBeTruthy();
-    expect(findClosestBy(article1, () => false)).toBe(null);
+  describe('closestBy', () => {
+    test('finds closest parent node starting from the base element', () => {
+      expect(findClosestBy(null, (el: HTMLElement) => el.dataset.test === '1')).toBe(null);
+      expect(findClosestBy(document.createElement('div'), (el: HTMLElement) => el.dataset.test === '1')).toBe(null);
+      expect(findClosestBy(btn2, (el: HTMLElement) => el.classList.contains('btn'))).toBe(btn2);
+      expect(findClosestBy(btn2, (el: HTMLElement) => el.dataset.test === '1')).toBe(row1);
+      expect(findClosestBy(btn2, (el: HTMLElement) => el.tagName.toLowerCase() === 'section')).toBeTruthy();
+      expect(findClosestBy(article1, () => false)).toBe(null);
+    });
+    test('finds closest parent node skipping the base element', () => {
+      expect(findClosestBy(btn2, (el: HTMLElement) => el.dataset.test === '1', true)).toBe(row1);
+    });
   });
 
-  // TODO: add more test to cover traversing utils
+  describe('findNext', () => {
+    test('returns first next sibling that matches passed selector', () => {
+      expect(findNext(btn1, '#btn2')).toBe(btn2);
+      expect(findNext(btn1, '#btn3')).toBe(btn3);
+    });
+    test('returns null if there are no next siblings matching passed selector', () => {
+      expect(findNext(btn2, '#btn1')).toBe(null);
+      expect(findNext(btn2, '#btn2')).toBe(null);
+    });
+  });
+
+  describe('findPrev', () => {
+    test('returns first previous sibling that matches passed selector', () => {
+      expect(findPrev(btn2, '#btn1')).toBe(btn1);
+      expect(findPrev(btn3, '#btn1')).toBe(btn1);
+    });
+    test('returns null if there are no previous siblings matching passed selector', () => {
+      expect(findPrev(btn2, '#btn3')).toBe(null);
+      expect(findPrev(btn2, '#btn2')).toBe(null);
+    });
+  });
+
+  describe('findParent', () => {
+    test('returns first parent element matching passed selector', () => {
+      expect(findParent(btn1, '#row1')).toBe(row1);
+      expect(findParent(btn2, '.container')).toBe(root);
+    });
+    test('returns null if there are no parent elements matching passed selector', () => {
+      expect(findParent(btn2, '#row2')).toBe(null);
+    });
+  });
+
+  describe('findClosest', () => {
+    test('returns the element or their parent that matches passed selector', () => {
+      expect(findClosest(btn1, '.btn')).toBe(btn1);
+      expect(findClosest(btn2, '.container')).toBe(root);
+    });
+    test('returns null if the element and their parents do not match passed selector', () => {
+      expect(findClosest(btn2, '#row2')).toBe(null);
+    });
+  });
+
+  describe('findAll', () => {
+    test('returns array of all matching elements in subtree', () => {
+      expect(findAll(row1, '.btn')).toStrictEqual([btn1, btn2, btn3, btn4, btn5]);
+      expect(findAll(row1, '#btn3')).toStrictEqual([btn3]);
+    });
+    test('returns empty array if there are no matching elements in subtree', () => {
+      expect(findAll(row1, '.btn-6')).toStrictEqual([]);
+    });
+  });
+
+  describe('findChildren', () => {
+    test('returns array of all children matching passed selector', () => {
+      expect(findChildren(row1, '.btn')).toStrictEqual([btn1, btn2, btn3, btn4, btn5]);
+      expect(findChildren(root, '.row')).toStrictEqual([row1, row2]);
+    });
+    test('returns empty array if there are no children matching passed selector', () => {
+      expect(findChildren(root, '.btn')).toStrictEqual([]);
+    });
+  });
 });