diff --git a/packages/dom/package.json b/packages/dom/package.json index 407cac0b..9c805333 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -35,6 +35,7 @@ "test": "NODE_ENV=test mocha" }, "dependencies": { + "@adobe/css-tools": "^4.4.3", "fast-deep-equal": "^3.1.3", "tslib": "^2.6.2" }, diff --git a/packages/dom/src/lib/ElementAssertion.ts b/packages/dom/src/lib/ElementAssertion.ts index 537d8084..e89aa48d 100644 --- a/packages/dom/src/lib/ElementAssertion.ts +++ b/packages/dom/src/lib/ElementAssertion.ts @@ -1,4 +1,7 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; +import equal from "fast-deep-equal"; + +import { getExpectedAndReceivedStyles } from "./helpers/helpers"; export class ElementAssertion extends Assertion { @@ -142,8 +145,85 @@ export class ElementAssertion extends Assertion { ); } - private getClassList(): string[] { - return this.actual.className.split(/\s+/).filter(Boolean); + /** + * Asserts that the element has the specified CSS styles. + * + * @example + * ``` + * expect(component).toHaveStyle({ color: 'green', display: 'block' }); + * ``` + * + * @param expected the expected CSS styles. + * @returns the assertion instance. + */ + + public toHaveStyle(expected: Partial): this { + + const [expectedStyle, receivedStyle] = getExpectedAndReceivedStyles(this.actual, expected); + + if (!expectedStyle || !receivedStyle) { + throw new Error("Currently there are no available styles."); + } + + const error = new AssertionError({ + actual: this.actual, + expected: expectedStyle, + message: `Expected the element to match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`, + }); + const invertedError = new AssertionError({ + actual: this.actual, + message: `Expected the element to NOT match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`, + }); + + return this.execute({ + assertWhen: equal(expectedStyle, receivedStyle), + error, + invertedError, + }); + } + + /** + * Asserts that the element has one or more of the specified CSS styles. + * + * @example + * ``` + * expect(component).toHaveSomeStyle({ color: 'green', display: 'block' }); + * ``` + * + * @param expected the expected CSS style/s. + * @returns the assertion instance. + */ + + public toHaveSomeStyle(expected: Partial): this { + + const [expectedStyle, elementProcessedStyle] = getExpectedAndReceivedStyles(this.actual, expected); + + if (!expectedStyle || !elementProcessedStyle) { + throw new Error("No available styles."); + } + + const hasSomeStyle = Object.entries(expectedStyle).some(([expectedProp, expectedValue]) => { + return Object.entries(elementProcessedStyle).some(([receivedProp, receivedValue]) => { + return equal(expectedProp, receivedProp) && equal(expectedValue, receivedValue); + }); + }); + + const error = new AssertionError({ + actual: this.actual, + message: `Expected the element to match some of the following styles:\n${JSON.stringify(expectedStyle, null, 2)}`, + }); + + const invertedError = new AssertionError({ + actual: this.actual, + // eslint-disable-next-line max-len + message: `Expected the element NOT to match some of the following styles:\n${JSON.stringify(expectedStyle, null, 2)}`, + }); + + return this.execute({ + assertWhen: hasSomeStyle, + error, + invertedError, + }); } /** @@ -181,4 +261,8 @@ export class ElementAssertion extends Assertion { invertedError, }); } + + private getClassList(): string[] { + return this.actual.className.split(/\s+/).filter(Boolean); + } } diff --git a/packages/dom/src/lib/helpers/helpers.ts b/packages/dom/src/lib/helpers/helpers.ts new file mode 100644 index 00000000..197cfd0c --- /dev/null +++ b/packages/dom/src/lib/helpers/helpers.ts @@ -0,0 +1,83 @@ +export interface CssAtRuleAST { + declarations: StyleDeclaration[]; + rules: Rule[]; +} + +interface Rule { + declarations: StyleDeclaration[]; + selectors: string[]; +} + +interface StyleDeclaration extends Record { + property: string; + value: string; +} + +const normalizeStyles = (css: Partial): +{ expectedStyle: StyleDeclaration; props: string[]; } => { + const normalizer = document.createElement("div"); + document.body.appendChild(normalizer); + + const { props, expectedStyle } = Object.entries(css).reduce( + (acc, [property, value]) => { + + if (typeof value !== "string") { + return acc; + } + + normalizer.style.setProperty(property, value); + + const normalizedValue = window + .getComputedStyle(normalizer) + .getPropertyValue(property) + .trim(); + + return { + expectedStyle: { + ...acc.expectedStyle, + [property]: normalizedValue, + }, + props: [...acc.props, property], + }; + }, + { expectedStyle: {} as StyleDeclaration, props: [] as string[] }, + ); + + document.body.removeChild(normalizer); + + return { expectedStyle, props }; +}; + +const getReceivedStyle = (props: string[], received: CSSStyleDeclaration): StyleDeclaration => { + return props.reduce((acc, prop) => { + const actualStyle = received.getPropertyValue(prop).trim(); + if (!actualStyle) { + return acc; + } + acc[prop] = actualStyle; + return acc; + }, {} as StyleDeclaration); +}; + +export const getExpectedAndReceivedStyles = +(actual: Element, expected: Partial): StyleDeclaration[] => { + if (!actual.ownerDocument.defaultView) { + throw new Error("The element is not attached to a document with a default view."); + } + if (!(actual instanceof HTMLElement)) { + throw new Error("The element is not an HTMLElement."); + } + + const window = actual.ownerDocument.defaultView; + + const rawElementStyles = window.getComputedStyle(actual); + + const { props, expectedStyle } = normalizeStyles(expected); + + const elementProcessedStyle = getReceivedStyle(props, rawElementStyles); + + return [ + expectedStyle, + elementProcessedStyle, + ]; +}; diff --git a/packages/dom/test/unit/lib/ElementAssertion.test.tsx b/packages/dom/test/unit/lib/ElementAssertion.test.tsx index 47bb1674..3719659d 100644 --- a/packages/dom/test/unit/lib/ElementAssertion.test.tsx +++ b/packages/dom/test/unit/lib/ElementAssertion.test.tsx @@ -257,12 +257,127 @@ describe("[Unit] ElementAssertion.test.ts", () => { const test = new ElementAssertion(divTest); expect(() => test.toHaveAllClasses("foo", "bar", "baz")) - .toThrowError(AssertionError) - .toHaveMessage('Expected the element to have all of these classes: "foo bar baz"'); + .toThrowError(AssertionError) + .toHaveMessage('Expected the element to have all of these classes: "foo bar baz"'); expect(test.not.toHaveAllClasses("foo", "bar", "baz")).toBeEqual(test); }); }); }); + describe(".toHaveStyle", () => { + context("when the element has the expected style", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render( +
); + const divTest = getByTestId("test-div"); + const test = new ElementAssertion(divTest); + + expect(test.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test); + + expect(() => test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })) + .toThrowError(AssertionError) + .toHaveMessage( + // eslint-disable-next-line max-len + 'Expected the element to NOT match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}', + ); + }); + }); + + context("when the element does not have the expected style", () => { + it("throws an assertion error", () => { + const { getByTestId } = render( +
, + ); + + const divTest = getByTestId("test-div"); + const test = new ElementAssertion(divTest); + + expect(() => test.toHaveStyle(({ border: "1px solid black", color: "red", display: "flex" }))) + .toThrowError(AssertionError) + .toHaveMessage( + // eslint-disable-next-line max-len + 'Expected the element to match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}', + ); + + expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test); + + }); + }); + context("when the element partially match the style", () => { + it("throws an assertion error", () => { + const { getByTestId } = render( +
, + ); + + const divTest = getByTestId("test-div"); + const test = new ElementAssertion(divTest); + + expect(() => test.toHaveStyle(({ color: "red", display: "flex" }))) + .toThrowError(AssertionError) + .toHaveMessage( + // eslint-disable-next-line max-len + 'Expected the element to match the following style:\n{\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}', + ); + + expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test); + + }); + }); + }); + + describe(".toHaveSomeStyle", () => { + context("when the element contains one or more expected styles", () => { + it("returns the assertion instance", () => { + const { getByTestId } = render( +
, + ); + const divTest = getByTestId("test-div"); + const test = new ElementAssertion(divTest); + + expect(test.toHaveSomeStyle({ color: "red", display: "flex", height: "3rem", width: "2rem" })).toBeEqual(test); + + expect(() => test.not.toHaveSomeStyle({ color: "blue" })) + .toThrowError(AssertionError) + // eslint-disable-next-line max-len + .toHaveMessage("Expected the element NOT to match some of the following styles:\n{\n \"color\": \"rgb(0, 0, 255)\"\n}"); + }); + }); + + context("when the element does not contain any of the expected styles", () => { + it("throws an assertion error", () => { + const { getByTestId } = render( +
, + ); + const divTest = getByTestId("test-div"); + const test = new ElementAssertion(divTest); + + expect(() => test.toHaveSomeStyle({ color: "red", display: "flex" })) + .toThrowError(AssertionError) + // eslint-disable-next-line max-len + .toHaveMessage("Expected the element to match some of the following styles:\n{\n \"color\": \"rgb(255, 0, 0)\",\n \"display\": \"flex\"\n}"); + + expect(test.not.toHaveSomeStyle({ border: "1px solid blue", color: "red", display: "flex" })).toBeEqual(test); + }); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 6d0e447c..d0f35bac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,13 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.4.3": + version: 4.4.3 + resolution: "@adobe/css-tools@npm:4.4.3" + checksum: 10/701379c514b7a43ca6681705a93cd57ad79565cfef9591122e9499897550cf324a5e5bb1bc51df0e7433cf0e91b962c90f18ac459dcc98b2431daa04aa63cb20 + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.2.0": version: 2.2.1 resolution: "@ampproject/remapping@npm:2.2.1" @@ -49,6 +56,7 @@ __metadata: version: 0.0.0-use.local resolution: "@assertive-ts/dom@workspace:packages/dom" dependencies: + "@adobe/css-tools": "npm:^4.4.3" "@assertive-ts/core": "workspace:^" "@testing-library/dom": "npm:^10.1.0" "@testing-library/react": "npm:^16.0.0"