Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
54 changes: 51 additions & 3 deletions packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Assertion, AssertionError } from "@assertive-ts/core";
import equal from "fast-deep-equal";

import { getReceivedStyle, normalizeStyles } from "./helpers/helpers";

export class ElementAssertion<T extends Element> extends Assertion<T> {

Expand Down Expand Up @@ -142,9 +145,50 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
);
}

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<CSSStyleDeclaration>): this {
if (!this.actual.ownerDocument.defaultView) {
throw new Error("The element is not attached to a document with a default view.");
}
if (!(this.actual instanceof HTMLElement)) {
throw new Error("The element is not an HTMLElement.");
}

const window = this.actual.ownerDocument.defaultView;

const received = window.getComputedStyle(this.actual);

const { props, expectedStyle } = normalizeStyles(expected);

const receivedStyle = getReceivedStyle(props, received);

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,
});
}

/**
* Helper method to assert the presence or absence of class names.
Expand Down Expand Up @@ -181,4 +225,8 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
invertedError,
});
}

private getClassList(): string[] {
return this.actual.className.split(/\s+/).filter(Boolean);
}
}
56 changes: 56 additions & 0 deletions packages/dom/src/lib/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export interface CssAtRuleAST {
declarations: StyleDeclaration[];
rules: Rule[];
}

interface Rule {
declarations: StyleDeclaration[];
selectors: string[];
}

interface StyleDeclaration extends Record<string, string> {
property: string;
value: string;
}

export const normalizeStyles = (css: Partial<CSSStyleDeclaration>):
{ 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 };
};

export const getReceivedStyle = (props: string[], received: CSSStyleDeclaration): StyleDeclaration => {
return props.reduce((acc, prop) => {
acc[prop] = received?.getPropertyValue(prop).trim();
return acc;
}, {} as StyleDeclaration);
};
76 changes: 74 additions & 2 deletions packages/dom/test/unit/lib/ElementAssertion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,84 @@ 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(
<div
className="foo bar test"
style={{ border: "1px solid black", color: "red", display: "flex" }}
data-testid="test-div"
/>);
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(
<div
className="foo bar test"
style={{ color: "blue", display: "block" }}
data-testid="test-div"
/>,
);

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(
<div
className="foo bar test"
style={{ border: "1px solid black", color: "blue", display: "block" }}
data-testid="test-div"
/>,
);

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);

});
});
});
});
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down