diff --git a/documentation/assertions/DOMElement/to-contain.md b/documentation/assertions/DOMElement/to-contain.md new file mode 100644 index 00000000..4f0664d7 --- /dev/null +++ b/documentation/assertions/DOMElement/to-contain.md @@ -0,0 +1,89 @@ +Assert that an element contains descendant elements satisfying a given specification. + +```js +var element = createElement(` +
+

Assert on text content

+

+ Learn about howto assert on the text content of a DOM element. +

+

+ Learn more here. +

+
+`); +``` + +Expect that an element contains a subtree satisfying a HTML fragment given as +string: + +```js +expect(element, 'to contain', '

Assert on text content

'); +``` + +You can also assert against a DOM element: + +```js +expect(element, 'to contain', createElement('

Assert on text content

')); +``` + +Finally you can also use the full power of [to +satisfy](http://unexpected.js.org/assertions/any/to-satisfy/) where you provide +the subtree you expect the subject to contain: + +```js +expect(element, 'to contain', { + children: [ + /^Learn/, + { + name: 'a', + attributes: { + href: 'https://example.com/learn' + }, + children: ['here'] + }, + '.' + ] +}); +``` + +When the assertion fails you get a nice descriptive error: + +```js +expect(element, 'to contain', '

Assert on all content

'); +``` + +```output +expected +
+

+ Assert on text content +

+

+ Learn about howto assert on the text content of a DOM element. +

+

+ Learn more + ... + . +

+
+to contain

Assert on all content

+ +

+ Assert on text content // should equal 'Assert on all content' + // + // -Assert on text content + // +Assert on all content +

+``` + +You can also assert that the element has no descendant elements satisfying the +given specification: + +```js +expect(element, 'not to contain', '

Assert on all content

'); +``` diff --git a/documentation/assertions/DOMNodeList/to-contain.md b/documentation/assertions/DOMNodeList/to-contain.md new file mode 100644 index 00000000..59bec06d --- /dev/null +++ b/documentation/assertions/DOMNodeList/to-contain.md @@ -0,0 +1,69 @@ +Assert that a DOM node list contains elements satisfying a given specification. + +See [to contain](../../DOMElement/to-contain/) for more details. + +```js +var element = createElement(` +
+

Numbers

+
+
    +
  1. One
  2. +
  3. Two
  4. +
  5. Three
  6. +
+
+`); + +expect(element, 'queried for', 'li', 'to contain', '
  • One
  • '); + +expect(element, 'to contain', { textContent: 'Three' }); + +expect(element, 'to contain', { name: 'li', textContent: /One|Two|Tree/ }); +``` + +In case of a failing expectation you get the following output: + +```js +expect( + element, + 'queried for', + 'li', + 'to contain', + '
  • Three
  • ' +); +``` + +```output +expected +

    Numbers


      +
    1. ...
    2. +
    3. ...
    4. +
    5. ...
    6. +
    +queried for li to contain '
  • Three
  • ' + expected + NodeList[ +
  • One
  • , +
  • Two
  • , +
  • Three
  • + ] + to contain
  • Three
  • + +
  • Three
  • +``` + +You can also assert that the element has no descendant elements satisfying the +given specification: + +```js +expect( + element, + 'queried for', + 'li', + 'not to contain', + '
  • Three
  • ' +); +``` diff --git a/src/index.js b/src/index.js index 56217998..b0d115c7 100644 --- a/src/index.js +++ b/src/index.js @@ -253,6 +253,25 @@ function stringifyEndTag(element) { } } +function ensureSupportedSpecOptions(options) { + const unsupportedOptions = Object.keys(options).filter( + key => + key !== 'attributes' && + key !== 'name' && + key !== 'children' && + key !== 'onlyAttributes' && + key !== 'textContent' + ); + + if (unsupportedOptions.length > 0) { + throw new Error( + `Unsupported option${ + unsupportedOptions.length === 1 ? '' : 's' + }: ${unsupportedOptions.join(', ')}` + ); + } +} + module.exports = { name: 'unexpected-dom', installInto(expect) { @@ -938,21 +957,7 @@ module.exports = { ' to [exhaustively] satisfy ', (expect, subject, value) => { const isHtml = isInsideHtmlDocument(subject); - const unsupportedOptions = Object.keys(value).filter( - key => - key !== 'attributes' && - key !== 'name' && - key !== 'children' && - key !== 'onlyAttributes' && - key !== 'textContent' - ); - if (unsupportedOptions.length > 0) { - throw new Error( - `Unsupported option${ - unsupportedOptions.length === 1 ? '' : 's' - }: ${unsupportedOptions.join(', ')}` - ); - } + ensureSupportedSpecOptions(value); const promiseByKey = { name: expect.promise(() => { @@ -1447,5 +1452,272 @@ module.exports = { return expect.shift(parseXml(subject)); } ); + + function scoreElementAgainstSpec(element, spec) { + const isTextSimilar = (value, valueSpec) => { + const actual = (value || '').trim().toLowerCase(); + if (typeof valueSpec === 'string') { + if (actual === valueSpec.trim().toLowerCase()) { + return true; + } + } else if (valueSpec instanceof RegExp) { + if (valueSpec.test(actual)) { + return true; + } + } else if (typeof valueSpec === 'function') { + return true; + } + + return false; + }; + + const isHtml = isInsideHtmlDocument(element); + + let score = 0; + + const nodeName = isHtml + ? element.nodeName.toLowerCase() + : element.nodeName; + + if (isTextSimilar(nodeName, spec.name)) { + score++; + } + + if (isTextSimilar(element.textContent, spec.textContent)) { + score++; + } + + if (typeof element.hasAttribute === 'function') { + const attributes = spec.attributes || {}; + const className = attributes['class']; + const style = attributes.style; + + if (className && element.hasAttribute('class')) { + if (typeof className === 'string') { + const expectedClasses = getClassNamesFromAttributeValue(className); + const actualClasses = getClassNamesFromAttributeValue( + element.getAttribute('class') + ); + + expectedClasses.forEach(expectedClass => { + if (actualClasses.indexOf(expectedClass) !== -1) { + score++; + } + }); + } else if (isTextSimilar(element.getAttribute('class'), className)) { + score++; + } + } + + if (style && element.hasAttribute('style')) { + const expectedStyles = + typeof style === 'string' ? styleStringToObject(style) : style; + const actualStyles = styleStringToObject( + element.getAttribute('style') + ); + + Object.keys(expectedStyles).forEach(styleName => { + const expectedStyle = expectedStyles[styleName]; + const actualStyle = actualStyles[styleName]; + + if (actualStyle) { + score++; + } + + if (isTextSimilar(actualStyle, expectedStyle)) { + score++; + } + }); + } + + const specialAttributes = ['style', 'class']; + const ids = ['id', 'data-test-id', 'data-testid']; + + Object.keys(attributes).forEach(attributeName => { + if (specialAttributes.indexOf(attributeName) !== -1) { + return; // skip + } + + if (element.hasAttribute(attributeName)) { + if (typeof attributes[attributeName] === 'boolean') { + score++; + } + + if ( + element.getAttribute(attributeName) === attributes[attributeName] + ) { + score += ids.indexOf(attributeName) === -1 ? 1 : 100; + } + } else if (typeof attributes[attributeName] === 'undefined') { + score++; + } + }); + } + + const expectedChildren = spec.children || []; + + expectedChildren.forEach((childSpec, i) => { + const child = element.childNodes[i]; + const childType = expect.findTypeOf(child); + + if (!child) { + return; + } + + if (typeof childSpec.nodeType === 'number') { + if (child.nodeType === childSpec.nodeType) { + if (childType.is('DOMElement')) { + // Element + score += scoreElementAgainstSpec( + element.childNodes[i], + convertDOMNodeToSatisfySpec(childSpec) + ); + } + + score++; + } else if (expect.findTypeOf(childSpec).is('DOMIgnoreComment')) { + score++; + } + } else if ( + childType.is('DOMElement') && + typeof childSpec === 'object' + ) { + score += scoreElementAgainstSpec(element.childNodes[i], childSpec); + } else if ( + childType.is('DOMTextNode') && + isTextSimilar(child.nodeValue, childSpec) + ) { + score++; + } + }); + + return score; + } + + function findMatchesWithGoodScore(data, spec) { + const elements = + typeof data.length === 'number' ? Array.from(data) : [data]; + + const result = []; + let bestScore = 0; + + elements.forEach(element => { + const score = scoreElementAgainstSpec(element, spec); + bestScore = Math.max(score, bestScore); + + if (score > 0 && score >= bestScore) { + result.push({ score, element }); + } + + for (var i = 0; i < element.childNodes.length; i += 1) { + const child = element.childNodes[i]; + if (child.nodeType === 1) { + result.push(...findMatchesWithGoodScore(child, spec)); + } + } + }); + + result.sort((a, b) => b.score - a.score); + + if (result.length > 0) { + const bestScore = result[0].score; + + return result.filter(({ score }) => score === bestScore); + } + + return result; + } + + expect.exportAssertion( + ' [not] to contain ', + (expect, subject, value) => { + const nodes = subject.childNodes || makeAttachedDOMNodeList(subject); + const isHtml = isInsideHtmlDocument( + subject.childNodes ? subject : nodes + ); + const valueType = expect.findTypeOf(value); + let spec = value; + + if (valueType.is('DOMElement')) { + spec = convertDOMNodeToSatisfySpec(value, isHtml); + } else if (valueType.is('string')) { + const documentFragment = isHtml + ? parseHtml(value, true) + : parseXml(value); + + if (documentFragment.childNodes.length !== 1) { + throw new Error( + 'HTMLElement to contain string: Only a single node is supported' + ); + } + + spec = convertDOMNodeToSatisfySpec( + documentFragment.childNodes[0], + isHtml + ); + + if (typeof spec === 'string') { + throw new Error( + 'HTMLElement to contain string: please provide a HTML structure as a string' + ); + } + + expect.argsOutput = output => + output.appendInspected(documentFragment.childNodes[0]); + + ensureSupportedSpecOptions(spec); + } + + const scoredElements = findMatchesWithGoodScore(nodes, spec); + + if (expect.flags.not) { + if (scoredElements.length > 0) { + return expect.withError( + () => + expect( + scoredElements.map(({ element }) => element), + 'not to have an item satisfying', + spec + ), + () => { + const bestMatch = scoredElements[0].element; + + expect.subjectOutput = output => + expect.inspect(subject, Infinity, output); + + expect.fail({ + diff: (output, diff, inspect, equal) => { + return output + .error('Found:') + .nl(2) + .appendInspected(bestMatch); + } + }); + } + ); + } + } else { + if (scoredElements.length === 0) { + expect.subjectOutput = output => + expect.inspect(subject, Infinity, output); + expect.fail(); + } + + return expect.withError( + () => + expect( + scoredElements.map(({ element }) => element), + 'to have an item satisfying', + spec + ), + () => { + const bestMatch = scoredElements[0].element; + + return expect(bestMatch, 'to satisfy', spec); + } + ); + } + } + ); } }; diff --git a/test/index.spec.js b/test/index.spec.js index ce3b6ff3..0116bb3f 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -2858,4 +2858,639 @@ describe('unexpected-dom', () => { '>' ); }); + + describe('to contain', () => { + describe('on a DOMDocument', () => { + describe('when given a DOMElement', () => { + it('succeeds if the given structure is present', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML to contain', + parseHtmlNode('Jane Doe') + ); + }); + }); + }); + + describe('on a DOMDocumentFragment', () => { + it('succeeds if the given structure is present', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML fragment to contain', + 'Jane Doe' + ); + }); + }); + + describe('on a DOMElement', () => { + it('succeeds if the given structure is present', () => { + expect( + parseHtmlNode( + '
    Hello Jane Doe
    ' + ), + 'to contain', + 'Jane Doe' + ); + }); + }); + + describe('on a DOMNodeList', () => { + it('succeeds if the given structure is present', () => { + expect( + '
    Nothing here
    Hello Jane Doe
    ', + 'when parsed as HTML fragment', + 'queried for', + 'div', + 'to contain', + 'Jane Doe' + ); + }); + }); + + describe('on an XMLDocument', () => { + it('succeeds if the given structure is present', () => { + expect( + 'foo bax baax', + 'parsed as XML', + 'to contain', + 'foo' + ); + }); + }); + + describe('when given a DOMElement', () => { + it('succeeds if the given structure is present', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + parseHtmlNode('Jane Doe') + ); + }); + }); + + describe('when given a spec', () => { + it('succeeds if the given structure is present', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + name: 'span', + attributes: { class: 'name' }, + textContent: expect.it('to match', /^Jane/).and('to have length', 8) + } + ); + }); + + it('supports searching for class names', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + attributes: { class: 'something-else name' } + } + ); + }); + + it('supports searching for inline-styles by an object', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + attributes: { style: { 'background-color': 'red' } } + } + ); + }); + + it('supports searching for inline-styles by a string', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + attributes: { style: 'background-color: red' } + } + ); + }); + + it('supports using regexps on the tag name', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + name: /^(i|span)$/, + textContent: 'Hello' + } + ); + }); + + it('supports using expect.it on the tag name', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + name: expect.it('to have length', 1), + textContent: 'Hello' + } + ); + }); + + it('supports using regexps on the class name', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + attributes: { + class: /^name something/ + } + } + ); + }); + + it('supports using expect.it on the class name', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + attributes: { + class: expect.it('to end with', 'else') + }, + textContent: 'Jane Doe' + } + ); + }); + + it('supports using declaring that the class should be undefined', () => { + expect( + '
    Hello! Hello
    ', + 'when parsed as HTML', + 'to contain', + { + attributes: { + class: undefined + }, + textContent: 'Hello' + } + ); + }); + + it('supports searching for boolean attributes', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + name: 'input', + attributes: { checked: true } + } + ); + }); + + it('supports searching for false boolean attributes', () => { + expect( + '
    ', + 'when parsed as HTML', + 'to contain', + { + name: 'input', + attributes: { checked: undefined } + } + ); + }); + + it('supports searching for a child element', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + name: 'span', + children: [ + parseHtmlNode('Hello'), + parseHtmlNode('') + ] + } + ); + }); + + it('supports the onlyAttributes flag', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + { + name: 'span', + attributes: { + class: 'greeting' + }, + onlyAttributes: true + } + ); + }); + }); + + describe('when given a string', () => { + it('succeeds if the given structure is present', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + 'Jane Doe' + ); + }); + + it('fails when given more than on node', () => { + expect( + () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + 'Jane Doe!' + ); + }, + 'to throw', + 'HTMLElement to contain string: Only a single node is supported' + ); + }); + }); + + it('supports only stating a subset of the classes', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + 'Jane Doe' + ); + }); + + it('supports searching for boolean attributes', () => { + expect( + '
    Hello Jane Doe
    ', + 'when parsed as HTML', + 'to contain', + '' + ); + }); + + it('supports searching for style values', () => { + expect( + '
    Hello Jane Doe!
    ', + 'when parsed as HTML', + 'to contain', + 'Jane Doe' + ); + }); + + it('takes ignore comments into account when searching children', () => { + expect( + '
    HelloJane Doe
    ', + 'when parsed as HTML', + 'to contain', + 'Hello' + ); + }); + + it('fails searching for a plain string', () => { + expect( + () => { + expect( + '
    HelloJane Doe
    ', + 'when parsed as HTML', + 'to contain', + 'Jane Doe' + ); + }, + 'to throw', + 'HTMLElement to contain string: please provide a HTML structure as a string' + ); + }); + + it('fails when matching against an element with no children', () => { + expect( + () => { + expect( + parseHtmlNode('
    '), + 'to contain', + 'Jane Doe' + ); + }, + 'to throw', + 'expected
    to contain Jane Doe' + ); + }); + + it('should not match directly on the subject', () => { + expect( + () => { + expect( + parseHtmlNode( + 'HelloJane Doe' + ), + 'to contain', + 'Hello' + ); + }, + 'to throw', + 'expected\n' + + '\n' + + ' Hello\n' + + ' Jane Doe\n' + + '\n' + + 'to contain Hello\n' + + '\n' + + '\n' + + " // missing { name: 'span', attributes: {}, children: [ 'Hello' ] }\n" + + ' Hello\n' + + '' + ); + }); + + it('fails without a diff if no good candidates can be found in the given structure', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    ' + ), + 'to contain', + 'John Doe' + ); + }, + 'to throw', + 'expected
    \n' + + 'to contain John Doe' + ); + }); + + it('fails with a diff if the given structure is not present', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    Hello Jane Doe
    ' + ), + 'to contain', + 'John Doe' + ); + }, + 'to throw', + 'expected\n' + + '
    \n' + + ' Hello\n' + + ' \n' + + ' Jane Doe\n' + + '
    \n' + + 'to contain John Doe\n' + + '\n' + + '\n' + + " Jane Doe // should equal 'John Doe'\n" + + ' //\n' + + ' // -Jane Doe\n' + + ' // +John Doe\n' + + '' + ); + }); + + it('allows tag names to be different while finding the best match', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    Hello Jane Doe and
    John Doe
    ' + ), + 'to contain', + '
    Jane Doe
    ' + ); + }, + 'to throw', + 'expected\n' + + '
    \n' + + ' Hello\n' + + ' \n' + + ' Jane Doe\n' + + ' and\n' + + '
    John Doe
    \n' + + '
    \n' + + 'to contain
    Jane Doe
    \n' + + '\n' + + "Jane Doe' + ); + }); + + it('matches on sub-trees when searching for the best match', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    Hello Jane Doe and
    John Doe
    ' + ), + 'to contain', + 'Jane Doe' + ); + }, + 'to throw', + 'expected\n' + + '
    \n' + + ' Hello\n' + + ' \n' + + ' ...\n' + + ' and\n' + + '
    John Doe
    \n' + + '
    \n' + + 'to contain Jane Doe\n' + + '\n' + + '\n' + + " Jane Doe\n' + + '' + ); + }); + + it('matches more strongly on ids when showing the best match', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    Hello Jane Doe and John Doe
    ' + ), + 'to contain', + 'John Doe' + ); + }, + 'to throw', + 'expected\n' + + '
    \n' + + ' Hello\n' + + ' \n' + + ' \n' + + ' Jane Doe\n' + + ' \n' + + ' and\n' + + ' John Doe\n' + + '
    \n' + + 'to contain John Doe\n' + + '\n' + + '\n' + + " Jane Doe // should equal 'John Doe'\n" + + ' //\n' + + ' // -Jane Doe\n' + + ' // +John Doe\n' + + '' + ); + }); + + it('fails if the children is expected but the target is empty', () => { + expect( + () => { + expect( + parseHtmlNode('
    '), + 'to contain', + 'Hello' + ); + }, + 'to throw', + 'expected
    to contain Hello\n' + + '\n' + + '\n' + + " // missing { name: 'i', attributes: {}, children: [ 'Hello' ] }\n" + + '' + ); + }); + + it('fails if the an ignored child is expected but the target is empty', () => { + expect( + () => { + expect( + parseHtmlNode('
    '), + 'to contain', + '' + ); + }, + 'to throw', + 'expected
    to contain \n' + + '\n' + + '\n' + + ' // missing \n' + + '' + ); + }); + + it('fails if more children is expected than what is available in the target', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    Helloworld
    ' + ), + 'to contain', + 'Hello!' + ); + }, + 'to throw', + 'expected
    ......
    \n' + + 'to contain Hello!\n' + + '\n' + + '\n' + + ' Hello\n' + + ' world\n' + + " // missing '!'\n" + + '' + ); + }); + + it('fails if less children is expected than what is available in the target', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    Helloworld!
    ' + ), + 'to contain', + 'Helloworld' + ); + }, + 'to throw', + 'expected
    ......!
    \n' + + 'to contain Helloworld\n' + + '\n' + + '\n' + + ' Hello\n' + + ' world\n' + + ' ! // should be removed\n' + + '' + ); + }); + }); + + describe('not to contain', () => { + it('succeeds if the given structure is not present', () => { + expect( + parseHtmlNode( + '
    Hello Jane Doe and John Doe
    ' + ), + 'not to contain', + 'John Doe' + ); + }); + + it("succeeds if the given structure doesn't match any descendant elements at all", () => { + expect( + parseHtmlNode( + '
    ' + ), + 'not to contain', + 'John Doe' + ); + }); + + it('succeeds if the element has no children', () => { + expect( + parseHtmlNode('
    '), + 'not to contain', + 'Jane Doe' + ); + }); + + it('shows a diff if the given structure is present', () => { + expect( + () => { + expect( + parseHtmlNode( + '
    Hello Jane Doe and John Doe
    ' + ), + 'not to contain', + 'Jane Doe' + ); + }, + 'to throw', + 'expected\n' + + '
    \n' + + ' Hello\n' + + ' \n' + + ' \n' + + ' Jane Doe\n' + + ' \n' + + ' and\n' + + ' John Doe\n' + + '
    \n' + + 'not to contain Jane Doe\n' + + '\n' + + 'Found:\n' + + '\n' + + '\n' + + ' Jane Doe\n' + + '' + ); + }); + }); });