/** * The MIT License (MIT) * Copyright (c) 2017 Kent C. Dodds * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. */ import type { MatcherResult, MatcherState } from 'vitest' import type { Locator } from '../locators' import { getElementFromUserInput, getMessage } from './utils' export default function toHaveClass( this: MatcherState, actual: Element | Locator, ...params: (string | RegExp)[] | [string, options?: { exact: boolean }] ): MatcherResult { const htmlElement = getElementFromUserInput(actual, toHaveClass, this) const { expectedClassNames, options } = getExpectedClassNamesAndOptions(params) const received = splitClassNames(htmlElement.getAttribute('class')) const expected = expectedClassNames.reduce( (acc, className) => { return acc.concat( typeof className === 'string' || !className ? splitClassNames(className) : className, ) }, [] as (string | RegExp)[], ) const hasRegExp = expected.some((className) => className instanceof RegExp) if (options.exact && hasRegExp) { throw new Error('Exact option does not support RegExp expected class names') } if (options.exact) { return { pass: isSubset(expected, received) && expected.length === received.length, message: () => { const to = this.isNot ? 'not to' : 'to' return getMessage( this, this.utils.matcherHint( `${this.isNot ? '.not' : ''}.toHaveClass`, 'element', this.utils.printExpected(expected.join(' ')), ), `Expected the element ${to} have EXACTLY defined classes`, expected.join(' '), 'Received', received.join(' '), ) }, } } return expected.length > 0 ? { pass: isSubset(expected, received), message: () => { const to = this.isNot ? 'not to' : 'to' return getMessage( this, this.utils.matcherHint( `${this.isNot ? '.not' : ''}.toHaveClass`, 'element', this.utils.printExpected(expected.join(' ')), ), `Expected the element ${to} have class`, expected.join(' '), 'Received', received.join(' '), ) }, } : { pass: this.isNot ? received.length > 0 : false, message: () => this.isNot ? getMessage( this, this.utils.matcherHint('.not.toHaveClass', 'element', ''), 'Expected the element to have classes', '(none)', 'Received', received.join(' '), ) : [ this.utils.matcherHint(`.toHaveClass`, 'element'), 'At least one expected class must be provided.', ].join('\n'), } } function getExpectedClassNamesAndOptions( params: (string | RegExp)[] | [string, options?: { exact: boolean }], ): { expectedClassNames: (string | RegExp)[] options: { exact: boolean } } { const lastParam = params.pop() let expectedClassNames, options if (typeof lastParam === 'object' && !(lastParam instanceof RegExp)) { expectedClassNames = params options = lastParam } else { expectedClassNames = params.concat(lastParam) options = { exact: false } } return { expectedClassNames: expectedClassNames as string[], options } } function splitClassNames(str: string | undefined | null): string[] { if (!str) { return [] } return str.split(/\s+/).filter((s) => s.length > 0) } function isSubset(subset: (string | RegExp)[], superset: string[]) { return subset.every((strOrRegexp) => typeof strOrRegexp === 'string' ? superset.includes(strOrRegexp) : superset.some((className) => strOrRegexp.test(className)), ) }