Skip to content

Commit

Permalink
Merge pull request #2087 from bendemboski/dom-element-descriptors
Browse files Browse the repository at this point in the history
feature: support IDOMElementDescriptors
  • Loading branch information
BobrImperator authored Jun 27, 2024
2 parents a420f27 + d393fef commit 81ca68e
Show file tree
Hide file tree
Showing 23 changed files with 293 additions and 74 deletions.
119 changes: 119 additions & 0 deletions packages/qunit-dom/lib/__tests__/descriptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, beforeEach, test, expect } from 'vitest';

import createDescriptor from '../descriptor';
import TestAssertions from '../helpers/test-assertions';
import {
resolveDOMElement,
resolveDOMElements,
resolveDescription,
createDescriptor as createDescriptorBase,
} from 'dom-element-descriptors';

describe('createQuery()', () => {
beforeEach(() => {
document.body.innerHTML =
'<div class="single"></div><div class="multiple"></div><div class="multiple"></div>';
});

test('it works with a selector', () => {
let expected = Array.from(document.querySelectorAll('.multiple'));
let descriptor = createDescriptor('.multiple', document);

expect(resolveDOMElement(descriptor)).toEqual(expected[0]);
expect(resolveDOMElements(descriptor)).toEqual(expected);
expect(resolveDescription(descriptor)).toEqual('.multiple');
});

test('selector respects the root element', () => {
document.body.innerHTML =
'<div class="multiple"><div class="root"><div class="multiple"></div></div>';
let root = document.querySelector('.root');
let [outOfRoot, inRoot] = Array.from(document.querySelectorAll('.multiple'));

expect(resolveDOMElement(createDescriptor('.multiple', document))).toEqual(outOfRoot);
expect(resolveDOMElements(createDescriptor('.multiple', document))).toEqual([
outOfRoot,
inRoot,
]);

expect(resolveDOMElement(createDescriptor('.multiple', root))).toEqual(inRoot);
expect(resolveDOMElements(createDescriptor('.multiple', root))).toEqual([inRoot]);
});

test('it works with an element', () => {
let expected = document.querySelector('.multiple');
let descriptor = createDescriptor(expected, document);

expect(resolveDOMElement(descriptor)).toEqual(expected);
expect(resolveDOMElements(descriptor)).toEqual([expected]);
expect(resolveDescription(descriptor)).toEqual('div.multiple');
});

test('it works with null', () => {
let descriptor = createDescriptor(null, document);

expect(resolveDOMElement(descriptor)).toEqual(null);
expect(resolveDOMElements(descriptor)).toEqual([]);
expect(resolveDescription(descriptor)).toEqual('<unknown>');
});

test('it works with a dom element descriptor', () => {
let expected = Array.from(document.querySelectorAll('.multiple'));
let descriptor = createDescriptor(
createDescriptorBase({ elements: expected, description: 'descriptoriffic' }),
document
);

expect(resolveDOMElement(descriptor)).toEqual(expected[0]);
expect(resolveDOMElements(descriptor)).toEqual(expected);
expect(resolveDescription(descriptor)).toEqual('descriptoriffic');
});

test('it works via assert.dom()', () => {
let assert = new TestAssertions();

assert
.dom(createDescriptorBase({ elements: Array.from(document.querySelectorAll('.single')) }))
.exists({ count: 1 });
assert
.dom(createDescriptorBase({ elements: Array.from(document.querySelectorAll('.multiple')) }))
.exists({ count: 2 });
assert
.dom(createDescriptorBase({ elements: Array.from(document.querySelectorAll('.single')) }))
.hasTagName('div');

expect(assert.results).toEqual([
{
actual: 'Element undefined exists once',
expected: 'Element undefined exists once',
message: 'Element undefined exists once',
result: true,
},
{
actual: 'Element undefined exists twice',
expected: 'Element undefined exists twice',
message: 'Element undefined exists twice',
result: true,
},
{
actual: 'div',
expected: 'div',
message: 'Element undefined has tagName div',
result: true,
},
]);
});

test('throws for unexpected parameter types', () => {
expect(() => createDescriptor(5, document)).toThrow('Unexpected Parameter: 5');
expect(() => createDescriptor(true, document)).toThrow('Unexpected Parameter: true');
expect(() => createDescriptor(undefined, document)).toThrow('Unexpected Parameter: undefined');
expect(() => createDescriptor({}, document)).toThrow('Unexpected Parameter: [object Object]');
expect(() => createDescriptor({ element: document.createElement('div') }, document)).toThrow(
'Unexpected Parameter: [object Object]'
);
expect(() => createDescriptor(document, document)).toThrow(
'Unexpected Parameter: [object Document]'
);
});
});
95 changes: 47 additions & 48 deletions packages/qunit-dom/lib/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ import isValid from './assertions/is-valid.js';
import isVisible from './assertions/is-visible.js';
import isDisabled from './assertions/is-disabled.js';
import matchesSelector from './assertions/matches-selector.js';
import elementToString from './helpers/element-to-string.js';
import collapseWhitespace from './helpers/collapse-whitespace.js';
import { toArray } from './helpers/node-list.js';
import {
type IDOMElementDescriptor,
resolveDOMElement,
resolveDOMElements,
resolveDescription,
} from 'dom-element-descriptors';
import createDescriptor from './descriptor';

export interface AssertionResult {
result: boolean;
Expand All @@ -29,11 +34,24 @@ type CSSStyleDeclarationProperty = keyof CSSStyleDeclaration;
type ActualCSSStyleDeclaration = Partial<Record<CSSStyleDeclarationProperty, unknown>>;

export default class DOMAssertions {
/**
* The target of our assertions
*/
private descriptor: IDOMElementDescriptor;
/**
* Whether we were constructed with an element, rather than a selector or
* descriptor. Used to make error messages more helpful.
*/
private wasPassedElement: boolean;

constructor(
private target: string | Element | null,
private rootElement: RootElement,
target: string | Element | null | IDOMElementDescriptor,
rootElement: RootElement,
private testContext: Assert
) {}
) {
this.descriptor = createDescriptor(target, rootElement);
this.wasPassedElement = target instanceof Element;
}

/**
* Assert an {@link HTMLElement} (or multiple) matching the `selector` exists.
Expand Down Expand Up @@ -1217,11 +1235,11 @@ export default class DOMAssertions {
* assert.dom('p.red').matchesSelector('div.wrapper p:last-child')
*/
matchesSelector(compareSelector: string, message?: string): DOMAssertions {
let targetElements = this.target instanceof Element ? [this.target] : this.findElements();
let targetElements = this.findElements();
let targets = targetElements.length;
let matchFailures = matchesSelector(targetElements, compareSelector);
let singleElement: boolean = targets === 1;
let selectedByPart = this.target instanceof Element ? 'passed' : `selected by ${this.target}`;
let selectedByPart = this.selectedBy;
let actual;
let expected;

Expand All @@ -1230,7 +1248,7 @@ export default class DOMAssertions {
if (!message) {
message = singleElement
? `The element ${selectedByPart} also matches the selector ${compareSelector}.`
: `${targets} elements, selected by ${this.target}, also match the selector ${compareSelector}.`;
: `${targets} elements, ${selectedByPart}, also match the selector ${compareSelector}.`;
}
actual = expected = message;
this.pushResult({ result: true, actual, expected, message });
Expand All @@ -1240,7 +1258,7 @@ export default class DOMAssertions {
if (!message) {
message = singleElement
? `The element ${selectedByPart} did not also match the selector ${compareSelector}.`
: `${matchFailures} out of ${targets} elements selected by ${this.target} did not also match the selector ${compareSelector}.`;
: `${matchFailures} out of ${targets} elements ${selectedByPart} did not also match the selector ${compareSelector}.`;
}
actual = singleElement ? message : `${difference} elements matched ${compareSelector}.`;
expected = singleElement
Expand All @@ -1263,19 +1281,19 @@ export default class DOMAssertions {
* assert.dom('input').doesNotMatchSelector('input[disabled]')
*/
doesNotMatchSelector(compareSelector: string, message?: string): DOMAssertions {
let targetElements = this.target instanceof Element ? [this.target] : this.findElements();
let targetElements = this.findElements();
let targets = targetElements.length;
let matchFailures = matchesSelector(targetElements, compareSelector);
let singleElement: boolean = targets === 1;
let selectedByPart = this.target instanceof Element ? 'passed' : `selected by ${this.target}`;
let selectedByPart = this.selectedBy;
let actual;
let expected;
if (matchFailures === targets) {
// the assertion is successful because no element matched the other selector.
if (!message) {
message = singleElement
? `The element ${selectedByPart} did not also match the selector ${compareSelector}.`
: `${targets} elements, selected by ${this.target}, did not also match the selector ${compareSelector}.`;
: `${targets} elements, ${selectedByPart}, did not also match the selector ${compareSelector}.`;
}
actual = expected = message;
this.pushResult({ result: true, actual, expected, message });
Expand All @@ -1285,7 +1303,7 @@ export default class DOMAssertions {
if (!message) {
message = singleElement
? `The element ${selectedByPart} must not also match the selector ${compareSelector}.`
: `${difference} elements out of ${targets}, selected by ${this.target}, must not also match the selector ${compareSelector}.`;
: `${difference} elements out of ${targets}, ${selectedByPart}, must not also match the selector ${compareSelector}.`;
}
actual = singleElement
? `The element ${selectedByPart} matched ${compareSelector}.`
Expand Down Expand Up @@ -1407,10 +1425,10 @@ export default class DOMAssertions {
* @returns (HTMLElement|null) a valid HTMLElement, or null
*/
private findTargetElement(): Element | null {
let el = this.findElement();
let el = resolveDOMElement(this.descriptor);

if (el === null) {
let message = `Element ${this.target || '<unknown>'} should exist`;
let message = `Element ${this.targetDescription} should exist`;
this.pushResult({ message, result: false, actual: undefined, expected: undefined });
return null;
}
Expand All @@ -1419,51 +1437,32 @@ export default class DOMAssertions {
}

/**
* Finds a valid HTMLElement from target
* Finds a collection of Element instances from target using querySelectorAll
* @private
* @returns (HTMLElement|null) a valid HTMLElement, or null
* @returns (Element[]) an array of Element instances
* @throws TypeError will be thrown if target is an unrecognized type
*/
private findElement(): Element | null {
if (this.target === null) {
return null;
} else if (typeof this.target === 'string') {
if (!this.rootElement) {
throw new Error('Cannot do selector-based queries without a root element');
}
return this.rootElement.querySelector(this.target);
} else if (this.target instanceof Element) {
return this.target;
} else {
throw new TypeError(`Unexpected Parameter: ${this.target}`);
}
private findElements(): Element[] {
return Array.from(resolveDOMElements(this.descriptor));
}

/**
* Finds a collection of Element instances from target using querySelectorAll
* @private
* @returns (Element[]) an array of Element instances
* @throws TypeError will be thrown if target is an unrecognized type
*/
private findElements(): Element[] {
if (this.target === null) {
return [];
} else if (typeof this.target === 'string') {
if (!this.rootElement) {
throw new Error('Cannot do selector-based queries without a root element');
}
return toArray(this.rootElement.querySelectorAll(this.target));
} else if (this.target instanceof Element) {
return [this.target];
} else {
throw new TypeError(`Unexpected Parameter: ${this.target}`);
}
private get targetDescription(): string {
return resolveDescription(this.descriptor) ?? 'undefined';
}

/**
* @private
*/
private get targetDescription(): string {
return elementToString(this.target);
private get selectedBy(): string {
if (this.wasPassedElement) {
return 'passed';
} else if (resolveDOMElement(this.descriptor)) {
return `selected by ${this.targetDescription}`;
} else {
return 'selected by null';
}
}
}
4 changes: 4 additions & 0 deletions packages/qunit-dom/lib/assertions/exists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export default function exists(options?: ExistsOptions | string, message?: strin
}

function format(selector: string, num?: number) {
if (selector === '<unknown>') {
selector = '<not found>';
}

if (num === undefined || num === null) {
return `Element ${selector} exists`;
} else if (num === 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/qunit-dom/lib/assertions/focused.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default function focused(message?: string) {

let result = document.activeElement === element;
let actual = elementToString(document.activeElement);
let expected = elementToString(this.target);
let expected = this.targetDescription;

if (!message) {
message = `Element ${expected} is focused`;
Expand Down
4 changes: 1 addition & 3 deletions packages/qunit-dom/lib/assertions/is-checked.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import elementToString from '../helpers/element-to-string.js';

export default function checked(message?: string) {
let element = this.findTargetElement();
if (!element) return;
Expand All @@ -21,7 +19,7 @@ export default function checked(message?: string) {
let expected = 'checked';

if (!message) {
message = `Element ${elementToString(this.target)} is checked`;
message = `Element ${this.targetDescription} is checked`;
}

this.pushResult({ result, actual, expected, message });
Expand Down
4 changes: 1 addition & 3 deletions packages/qunit-dom/lib/assertions/is-not-checked.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import elementToString from '../helpers/element-to-string.js';

export default function notChecked(message?: string) {
let element = this.findTargetElement();
if (!element) return;
Expand All @@ -21,7 +19,7 @@ export default function notChecked(message?: string) {
let expected = 'not checked';

if (!message) {
message = `Element ${elementToString(this.target)} is not checked`;
message = `Element ${this.targetDescription} is not checked`;
}

this.pushResult({ result, actual, expected, message });
Expand Down
4 changes: 1 addition & 3 deletions packages/qunit-dom/lib/assertions/is-not-required.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import elementToString from '../helpers/element-to-string.js';

export default function notRequired(message?: string) {
let element = this.findTargetElement();
if (!element) return;
Expand All @@ -19,7 +17,7 @@ export default function notRequired(message?: string) {
let expected = 'not required';

if (!message) {
message = `Element ${elementToString(this.target)} is not required`;
message = `Element ${this.targetDescription} is not required`;
}

this.pushResult({ result, actual, expected, message });
Expand Down
4 changes: 1 addition & 3 deletions packages/qunit-dom/lib/assertions/is-required.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import elementToString from '../helpers/element-to-string.js';

export default function required(message?: string) {
let element = this.findTargetElement();
if (!element) return;
Expand All @@ -19,7 +17,7 @@ export default function required(message?: string) {
let expected = 'required';

if (!message) {
message = `Element ${elementToString(this.target)} is required`;
message = `Element ${this.targetDescription} is required`;
}

this.pushResult({ result, actual, expected, message });
Expand Down
Loading

0 comments on commit 81ca68e

Please sign in to comment.